diff --git a/.gitattributes b/.gitattributes index 2f50e46e..3c5768b5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,24 @@ +# Repo default: LF in the blob AND in the working tree, for every tracked +# file. tan-cli#364: `feat/v06-batch` was authored largely from a Windows box +# whose `core.autocrlf` is `true`, and 111 files came back CRLF-converted -- +# `.github/workflows/release.yml`, `python/tan/cli.py`, `doctor_cmd.py`, +# `flash_cmd.py`, the parity fixtures, whole test modules. Every one of them +# then reads as a full-file replacement in review (a 38-line change to +# `bootstrap.py` rendered as 3668 changed lines) and `git diff --check` +# reported 11,788 findings, so the real diff could not be audited at all. +# +# The specific `text eol=lf` pins below predate this and stay: each one records +# WHY that path must be LF regardless of the default (byte-exact vendoring, +# a script that executes on a customer's machine, a gate anchored on ` +`). +# This line is the blanket floor under them, not a replacement for them. +# +# Every tracked file in this repo is text -- .py .rs .yaml .txt .md .json .c +# .conf .exit .yml .sh .toml .h .js .zsh .ps1 .lock .fish .env .bash -- so +# `text=auto` has no binary to misdetect here. Adding a binary asset later +# needs its own `-text` line. +* text=auto eol=lf + # Vendored `alp-sdk --emit scaffold` output is baked into the binary via # include_str! and byte-compared against a fresh LF emit by the cross-repo # parity gate. Force LF so a Windows CI checkout (autocrlf=true) cannot diff --git a/.github/workflows/clean-host.yml b/.github/workflows/clean-host.yml index f4b32bda..03ec773b 100644 --- a/.github/workflows/clean-host.yml +++ b/.github/workflows/clean-host.yml @@ -219,8 +219,8 @@ jobs: # A CLEAN venv, not the runner's shared interpreter -- PyInstaller # bundles whatever its hooks can see (measured 34349423 B dirty vs # ~13.7 MB clean in release.yml's own header). `.[monitor]` matches - # release.yml/getting-started.yml: an extra a --onefile binary - # advertises in --help must actually be bundled. + # release.yml/getting-started.yml: an extra a frozen binary advertises + # in --help must actually be bundled. - name: freeze tan (clean venv) if: ${{ !matrix.container }} shell: bash @@ -255,30 +255,38 @@ jobs: # The actual gate: --version / doctor / sdk list --online / bootstrap # --dry-run, on a genuinely clean HOME + empty cwd, both with and # without $ZEPHYR_BASE. See clean_host_smoke.py's module docstring for - # the full contract; it is the CONSUMER of build_binary.sh's dist/tan - # (or dist/tan.exe), never a rebuild of it. + # the full contract; it is the CONSUMER of build_binary.sh's onedir + # output at dist/tan/tan (or dist/tan/tan.exe), never a rebuild of it. + # tan-cli#349 moved the executable one level deeper (dist/tan/tan[.exe], + # not dist/tan[.exe]) -- this job runs it straight out of that folder + # rather than unpacking the sibling dist/tan.zip/.tar.gz archive, since + # both are byte-identical to what the archive contains and skipping the + # unpack step is one less thing that could go wrong here (the archive + # itself IS covered, separately, by release-asset-smoke below, which has + # no unpacked folder to fall back to since it starts from the download). # - # `matrix.ext` picks the exact name, NOT `[ -f python/dist/tan ] || - # BIN=python/dist/tan.exe` (what this used to be): this step's `shell:` - # is Git Bash/MSYS on the Windows runner, and MSYS's `[ -f ]` reports - # TRUE for an extension-less name whenever a same-stem `.exe` exists -- - # it resolves the PE lookup transparently, the same way `CreateProcess` - # would. So `[ -f "python/dist/tan" ]` was true with ONLY `tan.exe` on - # disk, the `||` fallback never ran, and the extension-less path was - # handed to `clean_host_smoke.py`, whose `Path.is_file()` has no such - # magic and correctly reported it missing (tan-cli#303 CI finding). The - # matrix already knows the platform at schedule time -- deriving the - # name from it is exact and shell-independent; release.yml uses the - # same `matrix.ext` field for the same reason. + # `matrix.ext` picks the exact name, NOT `[ -f python/dist/tan/tan ] || + # BIN=python/dist/tan/tan.exe` (what this used to be one level up): + # this step's `shell:` is Git Bash/MSYS on the Windows runner, and + # MSYS's `[ -f ]` reports TRUE for an extension-less name whenever a + # same-stem `.exe` exists -- it resolves the PE lookup transparently, + # the same way `CreateProcess` would. So `[ -f "python/dist/tan/tan" ]` + # was true with ONLY `tan.exe` on disk, the `||` fallback never ran, and + # the extension-less path was handed to `clean_host_smoke.py`, whose + # `Path.is_file()` has no such magic and correctly reported it missing + # (tan-cli#303 CI finding). The matrix already knows the platform at + # schedule time -- deriving the name from it is exact and + # shell-independent; release.yml uses the same `matrix.ext` field for + # the same reason. - name: clean-host smoke (--version / doctor / sdk list --online / bootstrap --dry-run) shell: bash env: - TAN_BIN: python/dist/tan${{ matrix.ext }} + TAN_BIN: python/dist/tan/tan${{ matrix.ext }} run: | set -euo pipefail if [ ! -f "$TAN_BIN" ]; then echo "::error::expected the freeze at ${TAN_BIN} (matrix ext '${{ matrix.ext }}') but it is missing. Checked only that exact name -- not a [ -f tan ] || [ -f tan.exe ] probe, which MSYS/Git-Bash cannot answer correctly (see the comment above this step)." - ls -la python/dist || echo "python/dist does not exist at all -- the freeze step above did not run or did not produce it." + ls -la python/dist/tan 2>/dev/null || ls -la python/dist || echo "python/dist does not exist at all -- the freeze step above did not run or did not produce it." exit 1 fi python python/scripts/clean_host_smoke.py --tan "$TAN_BIN" @@ -295,14 +303,20 @@ jobs: fail-fast: false matrix: include: + # Asset names mirror release.yml's contract exactly (tan-cli#349): + # one archive per target, not a raw binary. - os: windows-latest - asset: tan-x86_64-pc-windows-msvc.exe + asset: tan-x86_64-pc-windows-msvc.zip + ext: .exe - os: macos-15-intel - asset: tan-x86_64-apple-darwin + asset: tan-x86_64-apple-darwin.tar.gz + ext: "" - os: macos-15 - asset: tan-aarch64-apple-darwin + asset: tan-aarch64-apple-darwin.tar.gz + ext: "" - os: ubuntu-latest - asset: tan-x86_64-unknown-linux-gnu + asset: tan-x86_64-unknown-linux-gnu.tar.gz + ext: "" runs-on: ${{ matrix.os }} timeout-minutes: 10 permissions: @@ -337,8 +351,22 @@ jobs: mkdir -p dl gh release download "$tag" --repo alplabai/tan-cli \ --pattern "${{ matrix.asset }}" --dir dl --clobber - chmod +x "dl/${{ matrix.asset }}" + + # tan-cli#349: the published asset is now an archive of a --onedir + # freeze, not a raw executable -- unpack it before the smoke test can + # run anything. `shutil.unpack_archive` (stdlib, already have Python + # 3.12 from the setup-python step above) picks zip vs tar.gz from the + # extension itself, so this one call covers every leg in the matrix + # without a platform-specific unzip/tar branch. The archive's own + # top-level entry is `tan/` (see build_binary.sh), so the unpacked + # binary lands at dl/unpacked/tan/tan[.exe]. + - name: unpack the downloaded archive + shell: bash + run: | + set -euo pipefail + python -c "import shutil; shutil.unpack_archive('dl/${{ matrix.asset }}', 'dl/unpacked')" + chmod +x "dl/unpacked/tan/tan${{ matrix.ext }}" - name: clean-host smoke against the downloaded asset shell: bash - run: python python/scripts/clean_host_smoke.py --tan "dl/${{ matrix.asset }}" + run: python python/scripts/clean_host_smoke.py --tan "dl/unpacked/tan/tan${{ matrix.ext }}" diff --git a/.github/workflows/getting-started.yml b/.github/workflows/getting-started.yml index 993abac5..1c7a90d7 100644 --- a/.github/workflows/getting-started.yml +++ b/.github/workflows/getting-started.yml @@ -221,9 +221,56 @@ jobs: /tmp/venv/bin/pip install --quiet ".[monitor]" "pyinstaller>=6.10" PYTHON=/tmp/venv/bin/python bash scripts/build_binary.sh ' - install -m 0755 python/dist/tan "$HOME/.local/bin/tan" - command -v tan - tan --version + # build_binary.sh always emits a --onedir freeze (tan-cli#349): + # python/dist/tan/ is a DIRECTORY (tan + _internal/), not a single + # executable. What the install.sh step above left at + # $HOME/.local/bin/tan is NOT fixed, though, since tan-cli#356: for + # the LATEST release right now (v0.4.1) install.sh's own + # checksums.txt lookup finds no archive asset, takes the raw-binary + # branch, and installs the raw v0.4.1 executable straight to + # $HOME/.local/bin/tan with NO tan-cli-lib/ at all -- there is then + # no launcher to "swap the library tree" of, and the old + # `cp -r ...tan-cli-lib` + assume-a-launcher-exists version of this + # step left that raw v0.4.1 binary in place, untouched, on PATH. + # Every step below would then run v0.4.1, not this PR, with `tan + # --version` still reporting exit 0 -- the exact silent-pass this + # job's own file header exists to prevent. + # + # So this step no longer assumes a launcher is there to retarget: it + # (re)writes ONE, unconditionally, mirroring install.sh's own + # archive-layout launcher byte for byte -- overwriting a raw + # v0.4.1 executable, a stale launcher, or nothing, alike -- and then + # PROVES the swap took by comparing `tan --version` against this + # checkout's own TAN_VERSION rather than merely checking exit 0 + # (which a stale v0.4.1 binary also returns). + rm -rf "$HOME/.local/bin/tan-cli-lib" + cp -r python/dist/tan "$HOME/.local/bin/tan-cli-lib" + chmod +x "$HOME/.local/bin/tan-cli-lib/tan" + cat >"$HOME/.local/bin/tan" <<'LAUNCHER' + #!/bin/sh + # Generated by getting-started.yml, mirroring install.sh's own + # archive-layout launcher (tan-cli#349) -- do not edit by hand. + exec "$HOME/.local/bin/tan-cli-lib/tan" "$@" + LAUNCHER + chmod +x "$HOME/.local/bin/tan" + + pr_version="$(sed -n 's/^TAN_VERSION = "\(.*\)"$/\1/p' python/tan/version.py)" + if [ -z "$pr_version" ]; then + echo "::error::could not read TAN_VERSION out of python/tan/version.py -- the pattern this step's sed matches moved." >&2 + exit 1 + fi + resolved="$(command -v tan)" + if [ "$resolved" != "$HOME/.local/bin/tan" ]; then + echo "::error::'tan' resolves to ${resolved}, not \$HOME/.local/bin/tan -- something earlier on PATH shadows the swapped-in binary, so every step below would still test the wrong tan." >&2 + exit 1 + fi + actual="$(tan --version)" + expected="tan ${pr_version}" + if [ "$actual" != "$expected" ]; then + echo "::error::the PR-freeze swap did not take: 'tan --version' printed '${actual}', expected '${expected}' (this checkout's python/tan/version.py). Every step below would be exercising a stale release binary instead of this PR -- see tan-cli#356." >&2 + exit 1 + fi + echo "swapped in: ${actual}" # ---- 5. the SDK checkout -------------------------------------------- diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 0f0b08b7..9f9ee917 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -23,6 +23,21 @@ name: parity +# tan-cli#213: surface `client_payload.sdk_ref` in the Actions API's own +# `display_title` (what `run-name` sets) rather than only a job-log `::notice::` +# (see the "resolve alp-sdk ref" step below) -- alp-sdk's dispatch-confirmation +# poll can then filter on the exact ref it sent instead of "any run created +# after our dispatch epoch", which a concurrent push or other sender also +# satisfies. Only `repository_dispatch` carries `client_payload`; the other +# three triggers below (`push`, `pull_request`, and `workflow_call` from +# release.yml) render an empty string instead, which is NOT a blank/broken +# title -- GitHub's own rule is that an omitted-or-whitespace-only `run-name` +# falls back to the event-specific default (the commit message on `push`, the +# PR title on `pull_request`), and that default is strictly more informative +# than a constant `parity (push)` string would be. Same empty-string-means- +# "use the default" idiom as the `ref:` step below. +run-name: ${{ github.event_name == 'repository_dispatch' && format('parity (sdk {0})', github.event.client_payload.sdk_ref) || '' }} + on: # Direct pushes to `main` (an admin merge, a hotfix, a back-merge) open no PR, # so without this the three jobs below never ran on those commits at all -- diff --git a/.github/workflows/python-binaries.yml b/.github/workflows/python-binaries.yml index b2afe71b..094cc675 100644 --- a/.github/workflows/python-binaries.yml +++ b/.github/workflows/python-binaries.yml @@ -98,10 +98,10 @@ jobs: fail-fast: false matrix: include: - - asset: tan-x86_64-pc-windows-msvc.exe + - asset: tan-x86_64-pc-windows-msvc.zip os: windows-latest machine: "0x8664" # IMAGE_FILE_MACHINE_AMD64 - - asset: tan-aarch64-pc-windows-msvc.exe + - asset: tan-aarch64-pc-windows-msvc.zip # `windows-11-arm`, and it has to be: PyInstaller freezes the # interpreter it is RUNNING and cannot cross-compile, so building # this asset on `windows-latest` would upload an x86_64 binary under @@ -118,6 +118,13 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" + # build_binary.sh (tan-cli#349) now emits a --onedir freeze at + # dist/tan/tan.exe (+ dist/tan/_internal/) AND archives that folder to + # dist/tan.zip. The archive is the actual release-shaped asset, so it is + # what gets renamed/uploaded; the pre-archive dist/tan/tan.exe (with its + # _internal sibling still on disk right after the build) is what the + # `arch` and `verify` steps below read, since both need a RUNNABLE + # onedir tree, not a lone exe with no _internal beside it. - name: build shell: bash working-directory: python @@ -126,17 +133,19 @@ jobs: .venv-build/Scripts/python -m pip install --quiet --upgrade pip .venv-build/Scripts/python -m pip install --quiet -e ".[monitor]" $BUILD_DEPS PYTHON=.venv-build/Scripts/python.exe bash scripts/build_binary.sh - cp dist/tan.exe "${{ matrix.asset }}" + cp dist/tan.zip "${{ matrix.asset }}" # Same reason the macOS job runs `file`: PyInstaller freezes the host's # arch, so a wrong runner label produces a correctly NAMED asset of the # wrong architecture, and the extension selects by name without ever # inspecting the file. Read the PE COFF machine field rather than shelling # `file`, which is a Git-for-Windows accident rather than a guarantee: - # 0x8664 = AMD64, 0xaa64 = ARM64. + # 0x8664 = AMD64, 0xaa64 = ARM64. Reads the pre-archive onedir exe + # (dist/tan/tan.exe), not the renamed .zip asset -- a .zip has no PE + # header at all. - name: arch shell: bash run: | - python - "python/${{ matrix.asset }}" "${{ matrix.machine }}" <<'PY' + python - "python/dist/tan/tan.exe" "${{ matrix.machine }}" <<'PY' import struct, sys path, want = sys.argv[1], int(sys.argv[2], 16) with open(path, "rb") as fh: @@ -148,10 +157,20 @@ jobs: if machine != want: raise SystemExit(f"::error::{path} is not the architecture its asset name claims") PY + # Runs against the pre-archive onedir tree, not the .zip -- verify_binary.sh + # executes the binary (`"$BIN" --version`), which needs its _internal + # sibling directory on disk, not zipped up. + # + # RELATIVE arguments here, `$PWD/...` in the linux and macos jobs below, + # deliberately: tan-cli#361 was a script that lost a relative path across + # its own `cd`, and it survived because all three call sites happened to + # pass absolute ones. Git Bash is also the shell the fix's cd-into-dirname + # idiom is least obvious on. Keeping one call site on each form means the + # release path itself exercises both. - name: verify if: inputs.verify shell: bash - run: python/scripts/verify_binary.sh "$PWD/python/${{ matrix.asset }}" "$PWD/alp-sdk" + run: python/scripts/verify_binary.sh python/dist/tan/tan.exe alp-sdk - uses: actions/upload-artifact@v4 with: name: ${{ matrix.asset }} @@ -163,16 +182,16 @@ jobs: fail-fast: false matrix: include: - - asset: tan-x86_64-unknown-linux-gnu + - asset: tan-x86_64-unknown-linux-gnu.tar.gz os: ubuntu-latest libc: gnu - - asset: tan-aarch64-unknown-linux-gnu + - asset: tan-aarch64-unknown-linux-gnu.tar.gz os: ubuntu-24.04-arm libc: gnu - - asset: tan-x86_64-unknown-linux-musl + - asset: tan-x86_64-unknown-linux-musl.tar.gz os: ubuntu-latest libc: musl - - asset: tan-aarch64-unknown-linux-musl + - asset: tan-aarch64-unknown-linux-musl.tar.gz os: ubuntu-24.04-arm libc: musl runs-on: ${{ matrix.os }} @@ -193,6 +212,12 @@ jobs: # got copied out during the freeze investigation with `set -e` armed. The # script now also quarantines an over-ceiling artifact as # `dist/tan.oversized` so the `cp` below cannot ship one either way. + # + # build_binary.sh (tan-cli#349) emits a --onedir freeze at + # dist/tan/tan (+ dist/tan/_internal/) AND archives that folder to + # dist/tan.tar.gz. The archive is staged under the asset name for + # upload; `verify` below reads the pre-archive dist/tan/tan directly, + # since it EXECUTES the binary and needs its _internal sibling on disk. - name: build (${{ matrix.libc }}) run: | if [ "${{ matrix.libc }}" = musl ]; then @@ -210,7 +235,7 @@ jobs: /tmp/v/bin/pip install --quiet -e ".[monitor]" '"$BUILD_DEPS"' PYTHON=/tmp/v/bin/python bash scripts/build_binary.sh' fi - cp python/dist/tan "python/${{ matrix.asset }}" + cp python/dist/tan.tar.gz "python/${{ matrix.asset }}" # Verified in a runtime image with NO Python installed, which is the # actual claim being made about a frozen binary. `debian:bullseye-slim` # doubles as the glibc-2.31 floor check for the -gnu assets. @@ -220,7 +245,7 @@ jobs: image=debian:bullseye-slim [ "${{ matrix.libc }}" = musl ] && image=alpine:3.20 docker run --rm -v "$PWD:/w" -w /w "$image" \ - sh python/scripts/verify_binary.sh "/w/python/${{ matrix.asset }}" /w/alp-sdk + sh python/scripts/verify_binary.sh "/w/python/dist/tan/tan" /w/alp-sdk - uses: actions/upload-artifact@v4 with: name: ${{ matrix.asset }} @@ -232,7 +257,7 @@ jobs: fail-fast: false matrix: include: - - asset: tan-x86_64-apple-darwin + - asset: tan-x86_64-apple-darwin.tar.gz # THE INTEL LABEL, and it has to be. `macos-latest`, `macos-14` and # `macos-15` are all Apple silicon, and PyInstaller freezes the # interpreter it is RUNNING -- it cannot cross-compile. Using one of @@ -242,7 +267,7 @@ jobs: # runtime (`Bad CPU type in executable`), not in CI. `file` is run on # the artifact below for exactly this reason. os: macos-15-intel - - asset: tan-aarch64-apple-darwin + - asset: tan-aarch64-apple-darwin.tar.gz os: macos-latest runs-on: ${{ matrix.os }} steps: @@ -254,6 +279,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" + # build_binary.sh (tan-cli#349) emits a --onedir freeze at dist/tan/tan + # (+ dist/tan/_internal/) AND archives that folder to dist/tan.tar.gz. + # The archive is what gets renamed/uploaded; `arch` and `verify` below + # read the pre-archive dist/tan/tan directly, since both need the + # _internal sibling still on disk (an arch check on a lone binary would + # still work, but verify EXECUTES it, so keep them consistent). - name: build working-directory: python run: | @@ -261,15 +292,15 @@ jobs: .venv-build/bin/python -m pip install --quiet --upgrade pip .venv-build/bin/python -m pip install --quiet -e ".[monitor]" $BUILD_DEPS PYTHON=.venv-build/bin/python bash scripts/build_binary.sh - cp dist/tan "${{ matrix.asset }}" + cp dist/tan.tar.gz "${{ matrix.asset }}" # `file` proves the arch, because a PyInstaller build cannot: it always # produces the host's arch, so a wrong runner label yields a correctly # NAMED binary of the wrong architecture. - name: arch - run: file "python/${{ matrix.asset }}" + run: file "python/dist/tan/tan" - name: verify if: inputs.verify - run: bash python/scripts/verify_binary.sh "$PWD/python/${{ matrix.asset }}" "$PWD/alp-sdk" + run: bash python/scripts/verify_binary.sh "$PWD/python/dist/tan/tan" "$PWD/alp-sdk" - uses: actions/upload-artifact@v4 with: name: ${{ matrix.asset }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41a927a5..5375c35a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,869 +1,891 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# tan release pipeline — freeze per-platform `tan` binaries on a version tag and -# publish them as GitHub release assets for the alp-sdk-vscode downloader. -# -# =========================================================================== -# THE CONTRACT (alp-sdk-vscode's releaseAssetForTarget MUST match this exactly) -# =========================================================================== -# -# Tag scheme : v.. (SemVer, e.g. v0.1.0) -# The tag MUST equal the `tan` crate version in the workspace -# Cargo.toml ([workspace.package] version) — the verify-version -# job below fails the release if it does not. -# -# Assets : one RAW (uncompressed) binary per target triple, named -# tan- (Unix: no extension) -# tan-.exe (Windows) -# Download URL is therefore deterministic: -# https://github.com/alplabai/tan-cli/releases/download// -# Plus `checksums.txt` (sha256 of every binary), -# `envelope-contract.json` (the WHOLE issue-code registry, all -# three statuses and not a frozen-only subset — a consumer reads -# `status` to decide what each code promises — plus one golden -# envelope per command family; see contract/README.md), and a -# GitHub build-provenance attestation covering all of the above — -# verify with `gh attestation verify --repo alplabai/tan-cli`. -# -# The binaries are PyInstaller --onefile freezes of `python/` (the Python -# port), NOT cargo builds of `crates/` — tan-cli#271. `--onefile` is required, -# not a preference: the extension downloads a raw binary to ONE cached path -# and has no unpack step anywhere in it -# (alp-sdk-vscode/src/alpCli/service.ts:295). The ASSET NAMES keep the RUST -# target triples because service.ts:34-46 hardcodes them and builds the -# download URL from them; python/scripts/build_binary.sh:33-35 documents the -# same rename-on-upload from its own side. -# -# Targets : win32 x64 -> tan-x86_64-pc-windows-msvc.exe -# darwin x64 -> tan-x86_64-apple-darwin -# darwin arm64 -> tan-aarch64-apple-darwin -# linux x64 -> tan-x86_64-unknown-linux-gnu -# -# DELIBERATELY NOT PUBLISHED — an accepted 404 on those two hosts, not an -# oversight: tan-aarch64-pc-windows-msvc.exe and -# tan-aarch64-unknown-linux-musl (or -gnu). PyInstaller cannot cross-compile: -# every asset must be frozen on its own architecture (build_binary.sh:36). -# The reason is NOT "no arm64 runner exists" — `windows-11-arm` and -# `ubuntu-24.04-arm` are current hosted labels — it is that adding two more -# runner types was out of scope for this tag. Nor is it billing: this repo is -# PUBLIC (`gh repo view alplabai/tan-cli --json isPrivate` → false), so arm64 -# minutes are not a barrier. An earlier revision of this comment said they -# were "billed and plan-gated on a PRIVATE repo" — true once, false now, and -# exactly the kind of stale reason this block warns about. Recorded precisely -# because a wrong reason is what stops anyone revisiting: the blocker is a -# decision, not a platform limit, and it can be revisited whenever arm64 -# assets are wanted. -# -# The LINUX asset is `-gnu`, and that name is deliberate. It is frozen in -# python:3.12-slim-bullseye (Debian 11, glibc 2.31), so it IS a glibc binary; -# calling it `-musl` would bake a lie into a filename we support for years. -# Two rejected alternatives, both on MEASUREMENT rather than taste: -# * Alpine/musl — PyInstaller's musllinux bootloader -# (bootloader/Linux-64bit-intel-musl/run) carries ELF interpreter -# /lib/ld-musl-x86_64.so.1, so a musl freeze runs ONLY on musl distros. -# It is not the "static, runs on any libc" artefact the Rust -musl target -# produced, and shipping it would have broken every Ubuntu/Debian/Fedora -# user. -# * manylinux2014 — ships a STATIC-only CPython -# (`sysconfig.get_config_var("Py_ENABLE_SHARED")` is 0, no -# libpython3.12*.so anywhere in the image) and PyInstaller requires a -# shared libpython: "ERROR: Python was built without a shared library, -# which is required by PyInstaller." The whole Linux leg dies, and with -# `needs: build` the release job never runs — zero assets under a tag -# that is already pushed and irreversible. -# Debian 11's 2.31 is also exactly the floor the retired cargo-zigbuild pin -# (`x86_64-unknown-linux-gnu.2.31`) targeted, so nothing is lost against the -# Rust asset. -# -# The floor in the release notes is MEASURED over the PAYLOAD, inside the -# build container, and never off the outer ELF. `readelf -V dist/tan` reads -# only PyInstaller's vendored bootloader, whose own floor is GLIBC_2.14 no -# matter what image built it (measured: bullseye and trixie both report 2.14 -# there while the real floors are 2.30 and 2.38) — a constant that cannot -# detect the image regressing to a newer glibc, which is the entire point of -# measuring. The real floor lives in the appended archive: libpython plus the -# extension modules, enumerated from .build/tan/PKG-00.toc. -# -# service.ts:34-46 still maps linux/x64 to the MUSL triple, so the extension -# cannot download this asset. Deliberate, for this tag: SUPPORTED_CLI_VERSION -# is still pinned to the last Rust release, so the extension never reaches an -# RC at all and keeps using what it already has; RC testers install by hand. -# Repointing that entry travels with the pin move at GA (#268). -# -# SIZE: build_binary.sh fails the build above the per-class ceiling in -# python/scripts/artifact_ceilings.env — TAN_MAX_ARTIFACT_BYTES_DEFAULT= -# 16500000 (glibc, what every asset THIS release publishes uses) and -# TAN_MAX_ARTIFACT_BYTES_MUSL=18000000 (musl links libc statically and runs -# larger; not published here, see the Linux section above) — and prints -# what it measured. The TIGHTEST measurement to date is the Windows freeze -# at 14047624 B (tan-cli#304 added `truststore` + `certifi`'s bundled -# `cacert.pem` — a prior measurement of 13717947 B predates both) — about -# 2.3 MB of headroom against the DEFAULT ceiling — so -# the next runtime dependency added to python/pyproject.toml is plausibly -# the one that trips it, under a tag. (build_binary.sh and -# tests/conformance/test_packaged_binary.py both source/parse this one file -# rather than each carrying its own number — see artifact_ceilings.env's own -# header: a single flat 15000000 B ceiling used to REJECT a correct -# arm64-alpine build before it was split in two.) Raising a ceiling further -# is not the fix: it is the only thing that detects a dirty-interpreter -# build. -# -# See docs/release-contract.md for the full contract + the vscode mapping table. -# =========================================================================== - -name: release - -on: - push: - tags: - - "v*" - -permissions: - contents: write # create the release + upload assets (default GITHUB_TOKEN only) - -jobs: - # Fail fast: the tag must match the crate version before we build 8 targets. - verify-version: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - # THE SOURCE OF TRUTH IS `python/tan/version.py`'s `TAN_VERSION` — the - # string the shipped binary PRINTS, and the one alp-sdk-vscode compares - # against its SUPPORTED_CLI_VERSION. This step used to gate the tag on - # `grep -m1 '^version = ' Cargo.toml`, which versions the RUST crates: with - # the release assets now frozen from the Python package, that made a - # correct `v0.5.0` tag fail before a single asset was built (Cargo.toml - # said 0.4.1-dev while tan/version.py and pyproject.toml both said 0.5.0). - # - # A python script, not a grep, because two of the three files spell the - # same version differently: SemVer `0.5.0-dev` vs PEP 440 `0.5.0.dev0`. A - # string compare across that boundary is either a false failure or, worse, - # a false pass on a version nobody agreed to. The mapping is explicit and - # has its own `--selftest`. It keeps the npm-shim check the old step - # carried, because postinstall.js derives the asset tag as - # `TAG = v${pkg.version}` (npm-shim/postinstall.js:25) and the shim was six - # releases stale before that check existed. - # - # No `pip install` before it: the script imports only the stdlib, so this - # gate cannot fail for a reason unrelated to versions. - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: tag == TAN_VERSION == pyproject == npm-shim - shell: bash - run: python python/scripts/version_check.py --selftest --tag "$GITHUB_REF_NAME" - - # The tagged commit must pass the same gates a PR does — a tag can be cut from - # any commit, so "it was green on main" is an assumption, not a fact. - gates: - uses: ./.github/workflows/ci.yml - # `sdk_parity: true` used to be deliberately off here, on the grounds that - # `tan/planner/manifest.py:93,118` wrote `firmware_path: null` - # unconditionally while alp-sdk's own manifest emitter omits the key when - # unset -- a real divergence that would have redded every tag for a defect - # a tag cannot fix. - # - # That citation no longer matches the file: `tan/planner/manifest.py` is - # 104 lines (no line 118), and line 93 IS - # `if firmware_path is not None:` -- the write-only-when-set rule, not its - # absence. Fixed in abdd6f1 ("resync the relocated planner with the - # alp-sdk it actually ships"), which also closed the four other deltas - # from the same stale-relocation root cause (`sdk_compat.py` missing - # entirely, the dropped `"TBD"` note, the legacy `on_module.*` fallback). - # Re-measured here against a pinned alp-sdk checkout: the planner/manifest - # parity tests (`-k "manifest or system_manifest"`) pass in full, and - # `python/tests/gates` (the five files armed by parity.yml's seam1 job, - # jlink freshness deselected) passes clean against the exact alp-sdk - # commit tan/planner/ is audited against. One unrelated pre-existing - # failure was found while re-measuring - # (`test_bootstrap_command.py::test_the_fallback_constants_match_the_real_manifest_field_for_field`, - # a `prerequisites_posix` mismatch) -- it fails identically with or - # without `ALP_SDK_ROOT` bound, so it is not a `sdk_parity` regression and - # not this input's concern; it already reddens `ci.yml`'s `python` job - # today regardless of this flag and needs its own fix. - # - # A gate that cannot go green is not a gate, and one that is on-but-ignored - # is worse -- flipped on now that the divergence it was off for is closed. - with: - sdk_parity: true - # A called workflow inherits the caller's permissions; the gates compile - # third-party deps and need no write on the release. - permissions: - contents: read - - # ci.yml's own `python` job runs the general `python/tests` suite (see - # `gates` above), but only with `ALP_SDK_ROOT` bound when THIS caller passes - # `sdk_parity: true` — off above — and even then it never ran - # `python/tests/gates` against the alp-sdk commit tan/planner/ was actually - # audited against (it checks out alp-sdk's default branch, not a pinned - # audit SHA). `python_only: true` is what actually confines parity.yml's - # `workflow_call` run here to skipping seam2/first-blink and most of - # seam1-plan-shape's own steps (they read that input, negated, in their - # `if:`, not `github.event_name`: inside a called workflow `github` is the - # CALLER's context (this file's own `push` trigger), so - # `github.event_name != 'workflow_call'` is always true and cannot gate - # anything — an explicit input is the only way this caller can tell - # parity.yml which jobs/steps to skip). seam1-plan-shape's tests/gates block - # (audit-commit byte-hash gate + live jlink-freshness) is deliberately NOT - # gated on that input and always runs here too — it is the only place in - # either workflow that runs `python/tests/gates` against the commit - # tan/planner/ was actually audited against, so this job is what puts it on - # the release tag's vote. seam2/first-blink and seam1's OTHER steps already - # gate every commit on `main` as tan-cli's own PR check, so a release tag - # (cut from an already-green commit) gains nothing re-running THOSE here; - # see parity.yml's `workflow_call:` comment. - python-gates: - uses: ./.github/workflows/parity.yml - with: - python_only: true - permissions: - contents: read - - # One PyInstaller freeze of `python/` per runner. There is no cross-build step - # and there cannot be one: PyInstaller freezes the interpreter it is running - # under, so the runner IS the target. That is why two of the six triples the - # extension knows about are not published here — see the header for the real - # reason (it is a scope/plan decision, NOT the absence of arm64 runners). - build: - needs: [verify-version, gates, python-gates] - strategy: - fail-fast: false - matrix: - include: - - os: windows-latest - asset: tan-x86_64-pc-windows-msvc.exe - ext: .exe - # macos-15-intel / macos-15, NOT macos-13 / macos-14: the macOS 13 - # image is retired (gone from actions/runner-images, so `runs-on: - # macos-13` matches no runner and the job never schedules) and macOS - # 14 is flagged deprecated there. These two are the current Intel and - # Apple-silicon labels of the SAME OS version, which is what keeps the - # two darwin assets comparable. - - os: macos-15-intel - asset: tan-x86_64-apple-darwin - ext: "" - - os: macos-15 - asset: tan-aarch64-apple-darwin - ext: "" - # `container` both routes this leg through the docker step below AND - # is the single place the build image is named. - - os: ubuntu-latest - asset: tan-x86_64-unknown-linux-gnu - ext: "" - container: python:3.12-slim-bullseye - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - # 3.12 is the floor python/pyproject.toml declares, and the floor is what - # ships: `tan build` bakes the interpreter into every Zephyr slice and - # Zephyr's own python.cmake rejects anything older. - - uses: actions/setup-python@v5 - if: ${{ !matrix.container }} - with: - python-version: "3.12" - - # A CLEAN venv, not the runner's shared interpreter: PyInstaller bundles - # whatever its hooks can see, and the hosted images ship numpy/Pillow/ - # pywin32 — measured 34349423 B dirty vs ~13.7 MB clean, against the - # 15000000 B ceiling scripts/build_binary.sh asserts. Deps come from - # `pip install .` (pyproject is the one dependency list) rather than a - # hand-copied set that can drift from it. - # - # `.[monitor]`, WITH the extra. An extra is optional for a wheel because a - # wheel user can add it later; a customer holding a --onefile binary never - # can, and `tan monitor` is a command that binary advertises. Omitting it - # ships a dead command whose own error text says so - # (`monitor.pyserial-missing`: "A frozen `tan` binary bundles it at build - # time, so a binary built without that extra cannot gain it here"). Costs - # +73392 B measured, against >=1.2 MB of headroom in - # python/scripts/artifact_ceilings.env. - - name: freeze tan (PyInstaller, clean venv) - if: ${{ !matrix.container }} - shell: bash - working-directory: python - run: | - set -euo pipefail - python -m venv .venv-build - VENV_PY=.venv-build/bin/python - [ -x "$VENV_PY" ] || VENV_PY=.venv-build/Scripts/python.exe - "$VENV_PY" -m pip install --quiet --upgrade pip - "$VENV_PY" -m pip install --quiet ".[monitor]" "pyinstaller>=6.10" - PYTHON="$VENV_PY" bash scripts/build_binary.sh - # pytest AFTER the freeze, never before: the artifact is already built, - # so this cannot inflate it. tests/conformance/test_packaged_binary.py - # is the extension's own acceptance test (one file, <3 s --version, - # the --add-data scaffold templates) and it self-skips unless dist/ - # exists -- which is exactly what the step above just produced. A - # compile used to prove the asset ran; a freeze proves nothing until - # it is executed, so it is executed here. - "$VENV_PY" -m pip install --quiet pytest - "$VENV_PY" -m pytest tests/conformance/test_packaged_binary.py -q - - # The Linux freeze runs in an OLD-glibc container so the binary's floor is - # the CONTAINER's glibc, not the runner's. Freezing on bare ubuntu-latest - # links its glibc (2.39 on 24.04) and hands users `GLIBC_2.39 not found` - # — the exact defect the retired cargo-zigbuild `.2.31` pin existed to - # avoid. PyInstaller has no equivalent flag, so an old distro IS the - # mechanism. See the header for why this is neither Alpine/musl nor - # manylinux2014 (both were tried and both are disqualified by - # measurement, not preference). - # - # `docker run` from a normal job, NOT a job-level `container:`: checkout - # and upload-artifact then keep running on the host where their bundled - # Node works, and the image needs no git. - # - # The floor is measured HERE, in the image that produced the binary, over - # the PAYLOAD rather than the outer ELF: `.build/tan/PKG-00.toc` is a - # plain Python literal listing every file PyInstaller appended, so its - # BINARY/EXTENSION entries are exactly libpython + the extension modules - # + their .so dependencies. `readelf -V dist/tan` would report the - # bootloader's own GLIBC_2.14 under any image and is a lower bound only. - # It refuses (exit non-zero) rather than guessing if the TOC yields - # implausibly few native files or no GLIBC_ version at all — a wrong - # number here becomes a compatibility promise in the release notes. - - name: freeze tan (PyInstaller in ${{ matrix.container }}) + measure the glibc floor - if: ${{ matrix.container }} - shell: bash - run: | - set -euo pipefail - docker run --rm -v "$PWD:/src" -w /src/python "${{ matrix.container }}" bash -euc ' - # binutils, for objdump. PyInstaller shells out to it on Linux to - # walk each binary dependency and refuses outright without it: - # "ERROR: On Linux, objdump is required. It is typically provided by - # the '"'"'binutils'"'"' package". The -slim images do not carry it, and - # nothing before this line would notice -- the whole Linux leg dies - # at freeze time, and with `needs: build` the release job never runs, - # leaving zero assets under a tag that is already pushed. That is - # exactly what v0.5.0-rc1 hit on its first tag. - apt-get update -qq - apt-get install -y -qq --no-install-recommends binutils - python -m venv /tmp/venv - /tmp/venv/bin/pip install --quiet --upgrade pip - /tmp/venv/bin/pip install --quiet ".[monitor]" "pyinstaller>=6.10" - PYTHON=/tmp/venv/bin/python bash scripts/build_binary.sh - /tmp/venv/bin/pip install --quiet pytest pyelftools - /tmp/venv/bin/python -m pytest tests/conformance/test_packaged_binary.py -q - /tmp/venv/bin/python - <<"PY" - import ast, sys - from elftools.elf.elffile import ELFFile - from elftools.elf.gnuversions import GNUVerNeedSection - - data = ast.literal_eval(open(".build/tan/PKG-00.toc", encoding="utf-8").read()) - toc = [e for x in data if isinstance(x, list) - for e in x if isinstance(e, tuple) and len(e) == 3] - paths = [p for _, p, t in toc if t in ("BINARY", "EXTENSION")] - vers = set() - for p in paths: - with open(p, "rb") as f: - for sec in ELFFile(f).iter_sections(): - if isinstance(sec, GNUVerNeedSection): - for _, auxes in sec.iter_versions(): - vers.update(a.name for a in auxes if a.name.startswith("GLIBC_")) - if len(paths) < 5 or not vers: - sys.exit("payload scan found %d native files / %d GLIBC_ versions -- " - "refusing to guess a floor" % (len(paths), len(vers))) - floor = max(vers, key=lambda v: tuple(int(n) for n in v.split("_")[1].split("."))) - open("dist/glibc-floor.txt", "w").write(floor + "\n") - print("payload floor over %d native files: %s (saw: %s)" - % (len(paths), floor, " ".join(sorted(vers)))) - PY - ' - - # Its own artifact, NOT part of the asset set -- the release job pulls the - # binaries with `pattern: tan-*` so this can never leak into assets/ and - # be published as a release asset. - - name: upload the measured glibc floor - if: ${{ matrix.container }} - uses: actions/upload-artifact@v4 - with: - name: glibc-floor - path: python/dist/glibc-floor.txt - if-no-files-found: error - - - name: stage asset - shell: bash - run: cp "python/dist/tan${{ matrix.ext }}" "${{ matrix.asset }}" - - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.asset }} - path: ${{ matrix.asset }} - if-no-files-found: error - - release: - needs: build - runs-on: ubuntu-latest - # id-token/attestations are scoped to this job only (it's the one that - # attests the binaries); contents:write is re-declared here (an explicit - # job-level `permissions:` block replaces, not adds to, the workflow-level - # default) so this job keeps the ability to create the release. - permissions: - contents: write - id-token: write - attestations: write - steps: - - uses: actions/checkout@v4 - # `pattern: tan-*` and not "everything": assets/ is published verbatim - # (`files: assets/*`), so anything else this workflow uploads must be - # fetched separately or it becomes a release asset by accident. - - uses: actions/download-artifact@v4 - with: - pattern: tan-* - path: assets - merge-multiple: true - - uses: actions/download-artifact@v4 - with: - name: glibc-floor - path: meta - # The JSON envelope contract, as a downloadable artefact (issue #106). - # alp-sdk-vscode gates real behaviour on exact issue-code strings and - # unversioned `data` field names, and every one of those matches fails - # open — a rename is indistinguishable from "no problem" on the consumer - # side. Publishing the goldens lets the extension's own contract test - # diff against THIS instead of a hand-copied fixture that drifts. - # - # Pure re-packaging: every input is a committed file already gated by - # `cargo test -p alp-tan-cli --test contract`, so there is no fact here - # that can be right in the repo and wrong in the asset. Run it locally - # with `python3` from the repo root to see exactly what ships. - - name: Bundle the envelope contract - shell: bash - run: | - python3 - "${GITHUB_REF_NAME#v}" > assets/envelope-contract.json <<'PY' - import json, pathlib, sys - - root = pathlib.Path("contract") - registry = json.loads((root / "issue-codes.json").read_text(encoding="utf-8")) - envelopes = {} - for case in sorted(p for p in (root / "envelopes").iterdir() if p.is_dir()): - envelopes[case.name] = { - "args": [ - line.strip() - for line in (case / "args.txt").read_text(encoding="utf-8").splitlines() - if line.strip() - ], - "exitCode": int((case / "expected.exit").read_text(encoding="utf-8").strip()), - "envelope": json.loads((case / "expected.json").read_text(encoding="utf-8")), - } - json.dump( - { - "schemaVersion": 1, - "tanVersion": sys.argv[1], - "issueCodes": registry["issueCodes"], - "envelopes": envelopes, - }, - sys.stdout, - indent=2, - ) - PY - python3 -c "import json,sys; d=json.load(open('assets/envelope-contract.json')); \ - assert d['envelopes'] and d['issueCodes'], 'empty bundle'; \ - print('bundled', len(d['envelopes']), 'envelopes,', len(d['issueCodes']), 'issue codes')" - - name: Generate checksums.txt - shell: bash - working-directory: assets - run: sha256sum * > checksums.txt - - name: Attest build provenance - uses: actions/attest-build-provenance@v2 - with: - subject-path: assets/* - - name: Slice CHANGELOG section for the release notes - shell: bash - run: | - # Measured by the build leg, inside the image that produced the - # binary, over the appended payload -- see that step. It cannot be - # measured HERE: `readelf -V` on a onefile reads only the vendored - # bootloader (a container-invariant GLIBC_2.14), and the payload is - # inside the archive where the outer ELF's .gnu.version_r cannot see - # it. `|| true` because this step runs under `bash -eo pipefail`, - # where a failing substitution would abort before the guard below - # could ever print its reason. - GLIBC_FLOOR="$(cat meta/glibc-floor.txt 2>/dev/null || true)" - if [ -z "$GLIBC_FLOOR" ]; then - echo "::error::meta/glibc-floor.txt is missing or empty -- the Linux build leg did not report a measured floor, and the notes must not state one nothing measured." - exit 1 - fi - echo "measured glibc floor of the published Linux asset: ${GLIBC_FLOOR}" - - # The tag is vX.Y.Z; the CHANGELOG header is `## [X.Y.Z]` (no v-prefix, - # em-dash date). Extract that one section as the GitHub Release body. - VERSION="${GITHUB_REF_NAME#v}" - python3 - "$VERSION" <<'PY' > release_notes.md - import sys, re - version = sys.argv[1] - out, capturing, found = [], False, False - for line in open("CHANGELOG.md", encoding="utf-8"): - if re.match(rf"^## \[{re.escape(version)}\]", line): - capturing = found = True - continue - if capturing and line.startswith("## ["): - break - if capturing: - out.append(line) - body = "".join(out).strip() - # #212: this used to `print(body if body else f"See CHANGELOG.md for - # {version}.")` and exit 0, so a tag whose section was never written - # published a release whose entire body was that one sentence -- and - # nothing anywhere said so. The failure mode is not hypothetical: the - # version bump renames `## [Unreleased]` to `## [X.Y.Z]`, and a bump - # that edits the version files but forgets the CHANGELOG header leaves - # exactly this state. Checked against dev before this change: a v0.4.1 - # tag would have found no section and shipped the stub. - # - # A release with no notes is not a degraded release, it is a broken - # one -- the notes are the only human-readable record of what changed, - # and the tag is immutable once pushed. Fail before publishing, not - # after. `shell: bash` runs with `-eo pipefail`, so a non-zero exit - # here stops the job. - if not found: - sys.exit( - f"::error::CHANGELOG.md has no `## [{version}]` section, so this " - f"release would publish with an empty body. The version bump " - f"renames `## [Unreleased]` to `## [{version}] -- `; that " - f"edit is missing. Fix CHANGELOG.md on the release branch, then " - f"re-tag." - ) - if not body: - sys.exit( - f"::error::CHANGELOG.md's `## [{version}]` section is empty. A " - f"release body has to say what changed; write the section, then " - f"re-tag." - ) - print(body) - PY - cat >> release_notes.md <<'NOTES' - - ## Release assets - - Four binaries, each a single-file freeze of the Python `tan`: - - - `tan-x86_64-pc-windows-msvc.exe` -- Windows x64 - - `tan-x86_64-apple-darwin` / `tan-aarch64-apple-darwin` -- macOS - - `tan-x86_64-unknown-linux-gnu` -- Linux x64, frozen on Debian 11. - It requires **__GLIBC_FLOOR__** or newer -- measured from the - binary's own bundled payload at build time, not assumed from the - build image. Debian 11+ / Ubuntu 20.04+ / RHEL 9+ are comfortably - above it. - - There is no arm64 Windows and no arm64 Linux asset in this release, - and no `-musl` asset. A frozen binary has to be built on the - architecture it runs on, and this release builds on four runners; if - you need an arm64 Linux or arm64 Windows `tan`, install from source - (`pip install ./python`) and say so on the issue tracker. - - - Every binary + `checksums.txt` carries a GitHub build-provenance - attestation. Verify with: - `gh attestation verify --repo alplabai/tan-cli` - NOTES - # The heredoc above is quoted (no expansion -- it is full of backticks - # that would otherwise be command substitution), so the measured floor - # is substituted here instead. - sed -i "s/__GLIBC_FLOOR__/${GLIBC_FLOOR}/" release_notes.md - echo "----- release_notes.md -----"; cat release_notes.md - # A SemVer pre-release tag carries a hyphen in its version (`v0.4.0-rc1`); - # a real release never does. Both flags are derived from that one fact so - # they cannot disagree with each other or with the tag. - # - # This is load-bearing, not hygiene. Both installers resolve what `latest` - # means through GitHub, and GitHub excludes a release from `latest` ONLY - # when it is marked `prerelease`. Publishing an rc without these flags - # therefore hands it to every customer running the documented install - # command. Neither flag was set before, so the classification rested - # entirely on the action's default -- an unacceptable place for that blast - # radius to live. - # - # The two scripts ask two different endpoints -- `install.sh` follows the - # `/releases/latest` redirect, `install.ps1` reads the API's `tag_name` - # (see each script for why its host needs that one). Both exclude - # prereleases on the same flag, so they agree; verified against this repo - # with v0.4.0 marked prerelease, where both resolve `latest` to v0.3.1 - # rather than to the higher version number. - # - # `make_latest` is spelled as an explicit "true"/"false" string because the - # action takes a string, not a boolean. - - name: publish release - uses: softprops/action-gh-release@v2 - with: - files: assets/* - fail_on_unmatched_files: true - body_path: release_notes.md - prerelease: ${{ contains(github.ref_name, '-') }} - make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }} - - # The crates.io job is GONE (tan-cli#271). It published `alp-tan-cli` from - # `crates/`, and no asset in this release comes from `crates/` any more -- the - # four binaries above are PyInstaller freezes of `python/`. Keeping it behind - # a false `if:` would have left a `cargo publish` in the release path shipping - # a different program under the same name; deleting it is the only version of - # "must not run" that is actually true. The PyPI equivalent (`pip install - # alp-tan`, the name python/pyproject.toml already reserves) is a separate - # decision and deliberately not smuggled in here. - - # npm shim — `npm i -g @alplabai/tan` / `npx @alplabai/tan`. One release tag - # scheme (v*), one workflow. Gated on NPM_TOKEN; on a FINAL tag a missing - # token FAILS rather than skipping, mirroring the crates.io job above (#151). - # - # A pre-release tag is skipped here too, and this path was the sharpest of the - # three: `npm publish` below passes no `--tag`, so npm defaults the release to - # the `latest` dist-tag. An unguarded rc would therefore become plain - # `npm i -g @alplabai/tan` for every consumer -- and npm unpublish is far more - # restricted than a crates.io yank (72-hour window, refused outright once - # anything depends on it). - # - # The relaxation, when we want an rc installable: publish it with - # `--tag next` so `npm i @alplabai/tan@next` reaches it while `latest` stays - # on the last real release. Skipping is the smaller change and keeps the rc - # fully retractable, which is the point of cutting one. - publish_npm: - name: publish · npm shim - needs: release - if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-') }} - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - defaults: - run: - working-directory: npm-shim - # What this job actually DID, for `release_gate` to compare against what was - # declared (#237). Deliberately OBSERVED rather than re-derived from - # `NPM_PUBLISH_ENABLED`: re-reading the declaration would make the gate - # circular — it would assert the declaration equals itself and pass even if - # the job had done the opposite. `published=true` is written only AFTER - # `npm publish` returns, and `false` only by the branch that declines. - # - # Two step ids because exactly one of them runs; a skipped step contributes - # nothing, so the `||` picks whichever fired. - outputs: - published: ${{ steps.published.outputs.published || steps.declined.outputs.published }} - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - # Publishing is OPT-IN, off by default. Set the repository VARIABLE - # `TAN_NPM_PUBLISH` to `true` to arm it (Settings -> Secrets and variables - # -> Actions -> Variables). A variable, not a secret: the value is not - # sensitive, and a secret that gates behaviour is invisible in the UI. - # - # WHY off (#233): v0.4.1's publish failed with `npm error code EOTP` -- - # `NPM_TOKEN` is a classic/publish token on a 2FA account, so `npm publish` - # demands an interactive one-time password no CI run can answer. Only an - # npm AUTOMATION (or granular) token is exempt. That is an account-side - # fix, so the failure recurs on every final tag until it is made. - # - # A permanently-red job on the release workflow is worse than no job. It is - # the mirror of a gate that cannot fail: a job that can only fail teaches - # everyone to stop reading the one board where ignoring red is least - # affordable. So this is gated OFF rather than left red -- and the OFF path - # is LOUD (see `npm publishing is disabled` below), because the other - # failure this repo spent the week removing is a channel that quietly - # reports success while shipping nothing (#151). - NPM_PUBLISH_ENABLED: ${{ vars.TAN_NPM_PUBLISH == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "24" - registry-url: https://registry.npmjs.org - - # Runs whether or not publishing is armed. The shim is still a shipped - # artefact of this repo, and a shim that stops packing is worth catching - # while it is cheap rather than on the day the token is fixed. - - name: Pack (smoke — confirm the shim packs with its bin + postinstall) - run: | - npm pack --dry-run - node -e "const p=require('./package.json'); if(!p.bin||!p.bin.tan) throw new Error('shim missing bin.tan')" - - # The OFF path, and it says so out loud in the run summary rather than - # passing silently. Exits 0: not publishing is the DECISION here, not a - # failure, and the release itself is unaffected. - - name: npm publishing is disabled - id: declined - if: ${{ env.NPM_PUBLISH_ENABLED != 'true' }} - run: | - echo "published=false" >> "$GITHUB_OUTPUT" - { - echo "### npm: NOT PUBLISHED — deliberately disabled" - echo - echo "\`@alplabai/tan@${GITHUB_REF_NAME#v}\` was **not** published. The shim packed cleanly; publishing is gated off." - echo - echo "Reason: the configured \`NPM_TOKEN\` requires an interactive one-time password (\`npm error code EOTP\`), which no CI run can supply. It needs replacing with an npm **automation** token — see [#233](https://github.com/alplabai/tan-cli/issues/233)." - echo - echo "To arm this job once that is done, set the repository variable \`TAN_NPM_PUBLISH\` to \`true\`." - } >> "$GITHUB_STEP_SUMMARY" - echo "::notice::npm publish is gated off (TAN_NPM_PUBLISH is not 'true'). @alplabai/tan was NOT published for this tag; see #233." - - # Same rule as the crates.io job, and for the same reason: this job only - # runs on a final tag, so a missing token means the release advertises - # `npm i -g @alplabai/tan` for a package that does not exist. v0.4.0 did - # (#151). The pack smoke above still runs first, so a genuinely broken - # shim is reported as a broken shim rather than as a missing secret. - # The three ARMED steps below all carry the same gate. Left individually - # conditional rather than split into a second job, so the token refusal, - # the publish and the outcome record stay next to the pack smoke they - # belong with. - - name: Refuse to "publish" with no token - if: ${{ env.NPM_PUBLISH_ENABLED == 'true' && env.NPM_TOKEN == '' }} - run: | - echo "### npm: NOT PUBLISHED" >> "$GITHUB_STEP_SUMMARY" - echo "\`NPM_TOKEN\` is not set, so \`npm i -g @alplabai/tan\` will not resolve for this release. The shim packed cleanly." >> "$GITHUB_STEP_SUMMARY" - echo "::error::NPM_TOKEN is not set, but TAN_NPM_PUBLISH is 'true'. A FINAL release must not report a successful npm publish it did not perform (#151). Add the secret, or unset TAN_NPM_PUBLISH." - exit 1 - - - name: Publish to npm - if: ${{ env.NPM_PUBLISH_ENABLED == 'true' }} - run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - # Reached only if `npm publish` above returned 0 -- a failure fails the job - # before this runs, so `published=true` is an observation, not a claim. - - name: Record the npm outcome - id: published - if: ${{ env.NPM_PUBLISH_ENABLED == 'true' }} - run: | - echo "published=true" >> "$GITHUB_OUTPUT" - echo "### npm: published \`@alplabai/tan@${GITHUB_REF_NAME#v}\`" >> "$GITHUB_STEP_SUMMARY" - - # --------------------------------------------------------------------------- - # #237: a tag must not be able to skip its own publishes silently. - # - # v0.4.1 proved it could. One flaky unit test failed `gates`, so `build`, - # `release` and both publish jobs were marked SKIPPED, and the tag existed -- - # immutably -- with no Release, no assets, and `releases/latest` still pointing - # at the previous version. The only signal was a red check on a run nobody was - # necessarily watching. `skipped` is the right semantic for a conditional job - # and the wrong one for the publish leg of a tag, because a tag is a commitment: - # the version number is spent and `install.sh` resolves it the moment a Release - # appears. - # - # This is NOT an exemption list. An exemption list grows -- npm today, some - # future channel tomorrow -- and every addition is a place the invariant quietly - # stops covering something. The invariant is DECLARATION-RELATIVE instead, the - # same mechanism #219 used for issue codes: declare the intent, then assert the - # outcome against the declaration. - # - # 1. HARD, unconditional: a pushed `v*` tag MUST produce a Release with its - # assets. Nothing legitimately skips that, rc or final. - # 2. PER CHANNEL: the outcome must match the channel's declared intent. - # crates.io has no gate variable, so its intent is "always". npm's intent - # is `TAN_NPM_PUBLISH`; unset means it must decline and exit 0, armed means - # it must publish or fail. - # - # So a disarmed npm channel is not an exemption -- it SATISFIES the invariant, - # because declaration and outcome agree. And arming it changes the declaration, - # after which this gate demands a publish with no edit here. That is also why - # this cannot re-create the permanently-red board #239 removed. - release_gate: - name: release outcome matches intent - needs: [release, publish_npm] - # `always()`, or a skipped dependency skips the gate too and the whole point - # is lost. Tag-only: this workflow has no other trigger today, but the guard - # makes the scope explicit rather than inherited. - if: ${{ always() && startsWith(github.ref, 'refs/tags/') }} - runs-on: ubuntu-latest - steps: - - name: assert the release and every channel matched its declared intent - shell: bash - env: - RELEASE_RESULT: ${{ needs.release.result }} - NPM_RESULT: ${{ needs.publish_npm.result }} - NPM_PUBLISHED: ${{ needs.publish_npm.outputs.published }} - NPM_DECLARED: ${{ vars.TAN_NPM_PUBLISH == 'true' }} - run: | - set -euo pipefail - fail=0 - note() { echo "$1" >> "$GITHUB_STEP_SUMMARY"; } - bad() { echo "::error::$1"; note "- FAIL: $1"; fail=1; } - - note "### Release outcome vs declared intent" - note "" - note "| leg | declared | result | published |" - note "|---|---|---|---|" - note "| release | always | \`${RELEASE_RESULT}\` | - |" - note "| npm | \`TAN_NPM_PUBLISH=${NPM_DECLARED}\` | \`${NPM_RESULT}\` | \`${NPM_PUBLISHED:-}\` |" - note "" - - # 1. Unconditional. A tag with no Release behind it is the v0.4.1 state. - if [ "$RELEASE_RESULT" != "success" ]; then - bad "no GitHub Release was published for ${GITHUB_REF_NAME} (release job: ${RELEASE_RESULT}). A pushed tag must always produce a Release with its assets; the tag is now spent with nothing behind it." - fi - - # A prerelease tag (a hyphen in the version) declares that the registry - # channels do not run at all, which is what their own `if:` encodes. Both - # being `skipped` is then the CORRECT outcome, so stop here. - case "${GITHUB_REF_NAME}" in - *-*) - note "Prerelease tag: registry channels declared not-run, both \`skipped\` as expected." - [ "$fail" -eq 0 ] || exit 1 - echo "prerelease: release asserted, registry channels correctly skipped." - exit 0 - ;; - esac - - # 2b. npm against its declaration. The job must not have FAILED either - # way -- a disarmed channel still has to exit 0 having said so. - if [ "$NPM_RESULT" != "success" ]; then - bad "the npm job did not complete cleanly (job: ${NPM_RESULT}). Disarmed it must report NOT PUBLISHED and exit 0; armed it must publish." - # The job succeeded, so `published` MUST be one of the two values it - # writes. Anything else means the outputs wiring between `publish_npm` - # and this gate is broken, and the comparisons below would be reading - # nothing. - # - # This is the one hole `always()` + a declaration check cannot close by - # itself. An unresolvable `needs.publish_npm.outputs.published` -- - # renamed step id, removed `outputs:` block, a context GitHub does not - # populate -- evaluates to the EMPTY STRING; it does not error. And '' - # satisfies `!= "true"`, which is the disarmed-expected branch, which is - # the state tan is in today. So a broken wiring would pass GREEN on every - # disarmed tag and only surface at ARMING time, reported as "npm did not - # publish" when npm may have published fine and the gate is describing - # its own broken input. Arming is the moment this gate most needs to be - # trustworthy; it must not be the moment it starts lying. - # - # A gate that reads an empty input and reports "matched declared intent" - # is #151's silent success one level up, which is what this job exists to - # remove. - # - # STRUCTURAL PRECONDITION -- do not merge the publish jobs. - # - # "Empty means the wiring is broken" is only true because each channel is - # a SEPARATE JOB with its own `needs: release`. A failed `release` or a - # failed `publish_crates` cannot blank npm's self-report, because npm's - # job either ran and reported, or was skipped and is caught by the - # `NPM_RESULT` check above. So an empty `published` from a job that - # reports `success` really does mean the outputs wiring is broken, and - # nothing else. - # - # Collapse the channels into ordered STEPS of one job -- the obvious - # refactor, for speed or for a shared checkout -- and that stops holding: - # an earlier step failing aborts the job before the later channel's - # reporting step runs, its output comes back empty for a reason that has - # nothing to do with wiring, and this check fires a FALSE "wiring is - # broken" beside the true error. - # - # Not hypothetical. alp-sdk-vscode ported this gate, merged its four - # publish legs into one job's steps, and hit exactly that: a Marketplace - # failure aborted before the Open VSX steps, and their port reported a - # broken wiring next to the real failure -- misdiagnosing the one scenario - # the gate was built for. - # - # There is no test that catches this: the assertion still passes on every - # green tag, and only lies on a failing one. Hence the comment. - # - # Kept in the SAME elif chain rather than a separate `case`, so a broken - # wiring reports exactly one cause: a garbage value would otherwise also - # trip a declaration comparison it has nothing to compare against. - elif [ "$NPM_PUBLISHED" != "true" ] && [ "$NPM_PUBLISHED" != "false" ]; then - bad "the npm job reported published='${NPM_PUBLISHED:-}', which is neither true nor false. The outputs wiring between publish_npm and this gate is broken, so the declaration checks are comparing against nothing. Check publish_npm's \`outputs:\` block and the \`declined\`/\`published\` step ids." - elif [ "$NPM_DECLARED" = "true" ] && [ "$NPM_PUBLISHED" != "true" ]; then - bad "TAN_NPM_PUBLISH is 'true' but the npm job did not publish (published=${NPM_PUBLISHED:-}). An armed channel that ships nothing is the silent-success failure #151 removed." - elif [ "$NPM_DECLARED" != "true" ] && [ "$NPM_PUBLISHED" = "true" ]; then - bad "npm published while TAN_NPM_PUBLISH is not 'true'. The channel shipped something nobody declared." - fi - - if [ "$fail" -ne 0 ]; then - note "" - note "This tag did NOT ship as declared. See the annotations above." - exit 1 - fi - note "All legs matched their declared intent." +# SPDX-License-Identifier: Apache-2.0 +# +# tan release pipeline — freeze per-platform `tan` binaries on a version tag and +# publish them as GitHub release assets for the alp-sdk-vscode downloader. +# +# =========================================================================== +# THE CONTRACT (alp-sdk-vscode's releaseAssetForTarget MUST match this exactly) +# =========================================================================== +# +# Tag scheme : v.. (SemVer, e.g. v0.1.0) +# The tag MUST equal the `tan` crate version in the workspace +# Cargo.toml ([workspace.package] version) — the verify-version +# job below fails the release if it does not. +# +# Assets : one ARCHIVE per target triple (tan-cli#349 — was one raw +# uncompressed binary; see below), named +# tan-.tar.gz (Unix) +# tan-.zip (Windows) +# Download URL is therefore deterministic: +# https://github.com/alplabai/tan-cli/releases/download// +# Plus `checksums.txt` (sha256 of every archive), +# `envelope-contract.json` (the WHOLE issue-code registry, all +# three statuses and not a frozen-only subset — a consumer reads +# `status` to decide what each code promises — plus one golden +# envelope per command family; see contract/README.md), and a +# GitHub build-provenance attestation covering all of the above — +# verify with `gh attestation verify --repo alplabai/tan-cli`. +# +# The binaries are PyInstaller --onedir freezes of `python/` (the Python +# port), archived for distribution, NOT cargo builds of `crates/` — +# tan-cli#271 (the Python port) / tan-cli#349 (onedir + archive). --onedir, +# not --onefile: --onefile re-extracts its ~14 MB runtime into a fresh temp +# dir on EVERY invocation, and on macOS each extracted .dylib is unsigned +# (the parent's ad-hoc signature does not cover extracted copies), so the OS +# re-verifies every one of them on every launch — measured 13.25-19.74 s for +# `--version` on the published v0.5.0-rc4 macOS asset, which TIMED OUT +# against alp-sdk-vscode's own 3 s version-probe budget +# (vscodeAdapter.ts:1406). The old "REQUIRED, not a preference" reasoning +# here — that the extension downloads a raw binary to ONE cached path with +# no unpack step anywhere in it (service.ts:295) — is exactly the stale +# opposite-of-the-code comment tan-cli#259 warns about now that this +# pipeline emits an archive; unpacking it on the extension side is a +# SEPARATE unit of #349 landing independently in that repo. The ASSET NAMES +# keep the RUST target triples because service.ts:34-46 hardcodes them and +# builds the download URL from them; python/scripts/build_binary.sh +# documents the same rename-on-upload from its own side. +# +# Targets : win32 x64 -> tan-x86_64-pc-windows-msvc.zip +# darwin x64 -> tan-x86_64-apple-darwin.tar.gz +# darwin arm64 -> tan-aarch64-apple-darwin.tar.gz +# linux x64 -> tan-x86_64-unknown-linux-gnu.tar.gz +# +# DELIBERATELY NOT PUBLISHED — an accepted 404 on those two hosts, not an +# oversight: tan-aarch64-pc-windows-msvc.zip and +# tan-aarch64-unknown-linux-musl.tar.gz (or -gnu). PyInstaller cannot cross-compile: +# every asset must be frozen on its own architecture (build_binary.sh:36). +# The reason is NOT "no arm64 runner exists" — `windows-11-arm` and +# `ubuntu-24.04-arm` are current hosted labels — it is that adding two more +# runner types was out of scope for this tag. Nor is it billing: this repo is +# PUBLIC (`gh repo view alplabai/tan-cli --json isPrivate` → false), so arm64 +# minutes are not a barrier. An earlier revision of this comment said they +# were "billed and plan-gated on a PRIVATE repo" — true once, false now, and +# exactly the kind of stale reason this block warns about. Recorded precisely +# because a wrong reason is what stops anyone revisiting: the blocker is a +# decision, not a platform limit, and it can be revisited whenever arm64 +# assets are wanted. +# +# The LINUX asset is `-gnu`, and that name is deliberate. It is frozen in +# python:3.12-slim-bullseye (Debian 11, glibc 2.31), so it IS a glibc binary; +# calling it `-musl` would bake a lie into a filename we support for years. +# Two rejected alternatives, both on MEASUREMENT rather than taste: +# * Alpine/musl — PyInstaller's musllinux bootloader +# (bootloader/Linux-64bit-intel-musl/run) carries ELF interpreter +# /lib/ld-musl-x86_64.so.1, so a musl freeze runs ONLY on musl distros. +# It is not the "static, runs on any libc" artefact the Rust -musl target +# produced, and shipping it would have broken every Ubuntu/Debian/Fedora +# user. +# * manylinux2014 — ships a STATIC-only CPython +# (`sysconfig.get_config_var("Py_ENABLE_SHARED")` is 0, no +# libpython3.12*.so anywhere in the image) and PyInstaller requires a +# shared libpython: "ERROR: Python was built without a shared library, +# which is required by PyInstaller." The whole Linux leg dies, and with +# `needs: build` the release job never runs — zero assets under a tag +# that is already pushed and irreversible. +# Debian 11's 2.31 is also exactly the floor the retired cargo-zigbuild pin +# (`x86_64-unknown-linux-gnu.2.31`) targeted, so nothing is lost against the +# Rust asset. +# +# The floor in the release notes is MEASURED over the PAYLOAD, inside the +# build container, and never off the outer ELF. `readelf -V dist/tan/tan` +# reads only PyInstaller's vendored bootloader, whose own floor is +# GLIBC_2.14 no matter what image built it (measured: bullseye and trixie +# both report 2.14 there while the real floors are 2.30 and 2.38) — a +# constant that cannot detect the image regressing to a newer glibc, which +# is the entire point of measuring. The real floor lives in the collected +# onedir payload: libpython plus the extension modules, enumerated from +# .build/tan/PKG-00.toc (unchanged by --onedir vs --onefile — PyInstaller +# writes this TOC before the final packaging step either way). +# +# service.ts:34-46 still maps linux/x64 to the MUSL triple, so the extension +# cannot download this asset. Deliberate, for this tag: SUPPORTED_CLI_VERSION +# is still pinned to the last Rust release, so the extension never reaches an +# RC at all and keeps using what it already has; RC testers install by hand. +# Repointing that entry travels with the pin move at GA (#268). +# +# SIZE: build_binary.sh fails the build above the per-class ceiling in +# python/scripts/artifact_ceilings.env — TAN_MAX_ARTIFACT_BYTES_DEFAULT= +# 16500000 (glibc, what every asset THIS release publishes uses) and +# TAN_MAX_ARTIFACT_BYTES_MUSL=18000000 (musl links libc statically and runs +# larger; not published here, see the Linux section above) — and prints +# what it measured. The TIGHTEST measurement to date is the Windows freeze +# at 14047624 B (tan-cli#304 added `truststore` + `certifi`'s bundled +# `cacert.pem` — a prior measurement of 13717947 B predates both) — about +# 2.3 MB of headroom against the DEFAULT ceiling — so +# the next runtime dependency added to python/pyproject.toml is plausibly +# the one that trips it, under a tag. (build_binary.sh and +# tests/conformance/test_packaged_binary.py both source/parse this one file +# rather than each carrying its own number — see artifact_ceilings.env's own +# header: a single flat 15000000 B ceiling used to REJECT a correct +# arm64-alpine build before it was split in two.) Raising a ceiling further +# is not the fix: it is the only thing that detects a dirty-interpreter +# build. +# +# See docs/release-contract.md for the full contract + the vscode mapping table. +# =========================================================================== + +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: write # create the release + upload assets (default GITHUB_TOKEN only) + +jobs: + # Fail fast: the tag must match the crate version before we build 8 targets. + verify-version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # THE SOURCE OF TRUTH IS `python/tan/version.py`'s `TAN_VERSION` — the + # string the shipped binary PRINTS, and the one alp-sdk-vscode compares + # against its SUPPORTED_CLI_VERSION. This step used to gate the tag on + # `grep -m1 '^version = ' Cargo.toml`, which versions the RUST crates: with + # the release assets now frozen from the Python package, that made a + # correct `v0.5.0` tag fail before a single asset was built (Cargo.toml + # said 0.4.1-dev while tan/version.py and pyproject.toml both said 0.5.0). + # + # A python script, not a grep, because two of the three files spell the + # same version differently: SemVer `0.5.0-dev` vs PEP 440 `0.5.0.dev0`. A + # string compare across that boundary is either a false failure or, worse, + # a false pass on a version nobody agreed to. The mapping is explicit and + # has its own `--selftest`. It keeps the npm-shim check the old step + # carried, because postinstall.js derives the asset tag as + # `TAG = v${pkg.version}` (npm-shim/postinstall.js:25) and the shim was six + # releases stale before that check existed. + # + # No `pip install` before it: the script imports only the stdlib, so this + # gate cannot fail for a reason unrelated to versions. + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: tag == TAN_VERSION == pyproject == npm-shim + shell: bash + run: python python/scripts/version_check.py --selftest --tag "$GITHUB_REF_NAME" + + # The tagged commit must pass the same gates a PR does — a tag can be cut from + # any commit, so "it was green on main" is an assumption, not a fact. + gates: + uses: ./.github/workflows/ci.yml + # `sdk_parity: true` used to be deliberately off here, on the grounds that + # `tan/planner/manifest.py:93,118` wrote `firmware_path: null` + # unconditionally while alp-sdk's own manifest emitter omits the key when + # unset -- a real divergence that would have redded every tag for a defect + # a tag cannot fix. + # + # That citation no longer matches the file: `tan/planner/manifest.py` is + # 104 lines (no line 118), and line 93 IS + # `if firmware_path is not None:` -- the write-only-when-set rule, not its + # absence. Fixed in abdd6f1 ("resync the relocated planner with the + # alp-sdk it actually ships"), which also closed the four other deltas + # from the same stale-relocation root cause (`sdk_compat.py` missing + # entirely, the dropped `"TBD"` note, the legacy `on_module.*` fallback). + # Re-measured here against a pinned alp-sdk checkout: the planner/manifest + # parity tests (`-k "manifest or system_manifest"`) pass in full, and + # `python/tests/gates` (the five files armed by parity.yml's seam1 job, + # jlink freshness deselected) passes clean against the exact alp-sdk + # commit tan/planner/ is audited against. One unrelated pre-existing + # failure was found while re-measuring + # (`test_bootstrap_command.py::test_the_fallback_constants_match_the_real_manifest_field_for_field`, + # a `prerequisites_posix` mismatch) -- it fails identically with or + # without `ALP_SDK_ROOT` bound, so it is not a `sdk_parity` regression and + # not this input's concern; it already reddens `ci.yml`'s `python` job + # today regardless of this flag and needs its own fix. + # + # A gate that cannot go green is not a gate, and one that is on-but-ignored + # is worse -- flipped on now that the divergence it was off for is closed. + with: + sdk_parity: true + # A called workflow inherits the caller's permissions; the gates compile + # third-party deps and need no write on the release. + permissions: + contents: read + + # ci.yml's own `python` job runs the general `python/tests` suite (see + # `gates` above), but only with `ALP_SDK_ROOT` bound when THIS caller passes + # `sdk_parity: true` — off above — and even then it never ran + # `python/tests/gates` against the alp-sdk commit tan/planner/ was actually + # audited against (it checks out alp-sdk's default branch, not a pinned + # audit SHA). `python_only: true` is what actually confines parity.yml's + # `workflow_call` run here to skipping seam2/first-blink and most of + # seam1-plan-shape's own steps (they read that input, negated, in their + # `if:`, not `github.event_name`: inside a called workflow `github` is the + # CALLER's context (this file's own `push` trigger), so + # `github.event_name != 'workflow_call'` is always true and cannot gate + # anything — an explicit input is the only way this caller can tell + # parity.yml which jobs/steps to skip). seam1-plan-shape's tests/gates block + # (audit-commit byte-hash gate + live jlink-freshness) is deliberately NOT + # gated on that input and always runs here too — it is the only place in + # either workflow that runs `python/tests/gates` against the commit + # tan/planner/ was actually audited against, so this job is what puts it on + # the release tag's vote. seam2/first-blink and seam1's OTHER steps already + # gate every commit on `main` as tan-cli's own PR check, so a release tag + # (cut from an already-green commit) gains nothing re-running THOSE here; + # see parity.yml's `workflow_call:` comment. + python-gates: + uses: ./.github/workflows/parity.yml + with: + python_only: true + permissions: + contents: read + + # One PyInstaller freeze of `python/` per runner. There is no cross-build step + # and there cannot be one: PyInstaller freezes the interpreter it is running + # under, so the runner IS the target. That is why two of the six triples the + # extension knows about are not published here — see the header for the real + # reason (it is a scope/plan decision, NOT the absence of arm64 runners). + build: + needs: [verify-version, gates, python-gates] + strategy: + fail-fast: false + matrix: + include: + # asset now carries the archive extension directly (tan-cli#349): + # the release ships one archive per target, not a raw binary, so + # `matrix.asset` is already the final filename and needs no rename + # step beyond staging it out of `dist/`. + - os: windows-latest + asset: tan-x86_64-pc-windows-msvc.zip + archive_ext: zip + # macos-15-intel / macos-15, NOT macos-13 / macos-14: the macOS 13 + # image is retired (gone from actions/runner-images, so `runs-on: + # macos-13` matches no runner and the job never schedules) and macOS + # 14 is flagged deprecated there. These two are the current Intel and + # Apple-silicon labels of the SAME OS version, which is what keeps the + # two darwin assets comparable. + - os: macos-15-intel + asset: tan-x86_64-apple-darwin.tar.gz + archive_ext: tar.gz + - os: macos-15 + asset: tan-aarch64-apple-darwin.tar.gz + archive_ext: tar.gz + # `container` both routes this leg through the docker step below AND + # is the single place the build image is named. + - os: ubuntu-latest + asset: tan-x86_64-unknown-linux-gnu.tar.gz + archive_ext: tar.gz + container: python:3.12-slim-bullseye + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + # 3.12 is the floor python/pyproject.toml declares, and the floor is what + # ships: `tan build` bakes the interpreter into every Zephyr slice and + # Zephyr's own python.cmake rejects anything older. + - uses: actions/setup-python@v5 + if: ${{ !matrix.container }} + with: + python-version: "3.12" + + # A CLEAN venv, not the runner's shared interpreter: PyInstaller bundles + # whatever its hooks can see, and the hosted images ship numpy/Pillow/ + # pywin32 — measured 34349423 B dirty vs ~13.7 MB clean, against the + # 15000000 B ceiling scripts/build_binary.sh asserts. Deps come from + # `pip install .` (pyproject is the one dependency list) rather than a + # hand-copied set that can drift from it. + # + # `.[monitor]`, WITH the extra. An extra is optional for a wheel because a + # wheel user can add it later; a customer holding a frozen binary never + # can, and `tan monitor` is a command that binary advertises. Omitting it + # ships a dead command whose own error text says so + # (`monitor.pyserial-missing`: "A frozen `tan` binary bundles it at build + # time, so a binary built without that extra cannot gain it here"). Costs + # +73392 B measured, against >=1.2 MB of headroom in + # python/scripts/artifact_ceilings.env. + - name: freeze tan (PyInstaller, clean venv) + if: ${{ !matrix.container }} + shell: bash + working-directory: python + run: | + set -euo pipefail + python -m venv .venv-build + VENV_PY=.venv-build/bin/python + [ -x "$VENV_PY" ] || VENV_PY=.venv-build/Scripts/python.exe + "$VENV_PY" -m pip install --quiet --upgrade pip + "$VENV_PY" -m pip install --quiet ".[monitor]" "pyinstaller>=6.10" + PYTHON="$VENV_PY" bash scripts/build_binary.sh + # pytest AFTER the freeze, never before: the artifact is already built, + # so this cannot inflate it. tests/conformance/test_packaged_binary.py + # is the extension's own acceptance test (one file, <3 s --version, + # the --add-data scaffold templates) and it self-skips unless dist/ + # exists -- which is exactly what the step above just produced. A + # compile used to prove the asset ran; a freeze proves nothing until + # it is executed, so it is executed here. + "$VENV_PY" -m pip install --quiet pytest + "$VENV_PY" -m pytest tests/conformance/test_packaged_binary.py -q + + # The Linux freeze runs in an OLD-glibc container so the binary's floor is + # the CONTAINER's glibc, not the runner's. Freezing on bare ubuntu-latest + # links its glibc (2.39 on 24.04) and hands users `GLIBC_2.39 not found` + # — the exact defect the retired cargo-zigbuild `.2.31` pin existed to + # avoid. PyInstaller has no equivalent flag, so an old distro IS the + # mechanism. See the header for why this is neither Alpine/musl nor + # manylinux2014 (both were tried and both are disqualified by + # measurement, not preference). + # + # `docker run` from a normal job, NOT a job-level `container:`: checkout + # and upload-artifact then keep running on the host where their bundled + # Node works, and the image needs no git. + # + # The floor is measured HERE, in the image that produced the binary, over + # the PAYLOAD rather than the outer ELF: `.build/tan/PKG-00.toc` is a + # plain Python literal listing every file PyInstaller appended, so its + # BINARY/EXTENSION entries are exactly libpython + the extension modules + # + their .so dependencies. `readelf -V dist/tan` would report the + # bootloader's own GLIBC_2.14 under any image and is a lower bound only. + # It refuses (exit non-zero) rather than guessing if the TOC yields + # implausibly few native files or no GLIBC_ version at all — a wrong + # number here becomes a compatibility promise in the release notes. + - name: freeze tan (PyInstaller in ${{ matrix.container }}) + measure the glibc floor + if: ${{ matrix.container }} + shell: bash + run: | + set -euo pipefail + docker run --rm -v "$PWD:/src" -w /src/python "${{ matrix.container }}" bash -euc ' + # binutils, for objdump. PyInstaller shells out to it on Linux to + # walk each binary dependency and refuses outright without it: + # "ERROR: On Linux, objdump is required. It is typically provided by + # the '"'"'binutils'"'"' package". The -slim images do not carry it, and + # nothing before this line would notice -- the whole Linux leg dies + # at freeze time, and with `needs: build` the release job never runs, + # leaving zero assets under a tag that is already pushed. That is + # exactly what v0.5.0-rc1 hit on its first tag. + apt-get update -qq + apt-get install -y -qq --no-install-recommends binutils + python -m venv /tmp/venv + /tmp/venv/bin/pip install --quiet --upgrade pip + /tmp/venv/bin/pip install --quiet ".[monitor]" "pyinstaller>=6.10" + PYTHON=/tmp/venv/bin/python bash scripts/build_binary.sh + /tmp/venv/bin/pip install --quiet pytest pyelftools + /tmp/venv/bin/python -m pytest tests/conformance/test_packaged_binary.py -q + /tmp/venv/bin/python - <<"PY" + import ast, sys + from elftools.elf.elffile import ELFFile + from elftools.elf.gnuversions import GNUVerNeedSection + + data = ast.literal_eval(open(".build/tan/PKG-00.toc", encoding="utf-8").read()) + toc = [e for x in data if isinstance(x, list) + for e in x if isinstance(e, tuple) and len(e) == 3] + paths = [p for _, p, t in toc if t in ("BINARY", "EXTENSION")] + vers = set() + for p in paths: + with open(p, "rb") as f: + for sec in ELFFile(f).iter_sections(): + if isinstance(sec, GNUVerNeedSection): + for _, auxes in sec.iter_versions(): + vers.update(a.name for a in auxes if a.name.startswith("GLIBC_")) + if len(paths) < 5 or not vers: + sys.exit("payload scan found %d native files / %d GLIBC_ versions -- " + "refusing to guess a floor" % (len(paths), len(vers))) + floor = max(vers, key=lambda v: tuple(int(n) for n in v.split("_")[1].split("."))) + open("dist/glibc-floor.txt", "w").write(floor + "\n") + print("payload floor over %d native files: %s (saw: %s)" + % (len(paths), floor, " ".join(sorted(vers)))) + PY + ' + + # Its own artifact, NOT part of the asset set -- the release job pulls the + # binaries with `pattern: tan-*` so this can never leak into assets/ and + # be published as a release asset. + - name: upload the measured glibc floor + if: ${{ matrix.container }} + uses: actions/upload-artifact@v4 + with: + name: glibc-floor + path: python/dist/glibc-floor.txt + if-no-files-found: error + + - name: stage asset + shell: bash + run: cp "python/dist/tan.${{ matrix.archive_ext }}" "${{ matrix.asset }}" + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset }} + path: ${{ matrix.asset }} + if-no-files-found: error + + release: + needs: build + runs-on: ubuntu-latest + # id-token/attestations are scoped to this job only (it's the one that + # attests the binaries); contents:write is re-declared here (an explicit + # job-level `permissions:` block replaces, not adds to, the workflow-level + # default) so this job keeps the ability to create the release. + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + # `pattern: tan-*` and not "everything": assets/ is published verbatim + # (`files: assets/*`), so anything else this workflow uploads must be + # fetched separately or it becomes a release asset by accident. + - uses: actions/download-artifact@v4 + with: + pattern: tan-* + path: assets + merge-multiple: true + - uses: actions/download-artifact@v4 + with: + name: glibc-floor + path: meta + # The JSON envelope contract, as a downloadable artefact (issue #106). + # alp-sdk-vscode gates real behaviour on exact issue-code strings and + # unversioned `data` field names, and every one of those matches fails + # open — a rename is indistinguishable from "no problem" on the consumer + # side. Publishing the goldens lets the extension's own contract test + # diff against THIS instead of a hand-copied fixture that drifts. + # + # Pure re-packaging: every input is a committed file already gated by + # `cargo test -p alp-tan-cli --test contract`, so there is no fact here + # that can be right in the repo and wrong in the asset. Run it locally + # with `python3` from the repo root to see exactly what ships. + - name: Bundle the envelope contract + shell: bash + run: | + python3 - "${GITHUB_REF_NAME#v}" > assets/envelope-contract.json <<'PY' + import json, pathlib, sys + + root = pathlib.Path("contract") + registry = json.loads((root / "issue-codes.json").read_text(encoding="utf-8")) + envelopes = {} + for case in sorted(p for p in (root / "envelopes").iterdir() if p.is_dir()): + envelopes[case.name] = { + "args": [ + line.strip() + for line in (case / "args.txt").read_text(encoding="utf-8").splitlines() + if line.strip() + ], + "exitCode": int((case / "expected.exit").read_text(encoding="utf-8").strip()), + "envelope": json.loads((case / "expected.json").read_text(encoding="utf-8")), + } + json.dump( + { + "schemaVersion": 1, + "tanVersion": sys.argv[1], + "issueCodes": registry["issueCodes"], + "envelopes": envelopes, + }, + sys.stdout, + indent=2, + ) + PY + python3 -c "import json,sys; d=json.load(open('assets/envelope-contract.json')); \ + assert d['envelopes'] and d['issueCodes'], 'empty bundle'; \ + print('bundled', len(d['envelopes']), 'envelopes,', len(d['issueCodes']), 'issue codes')" + - name: Generate checksums.txt + shell: bash + working-directory: assets + run: sha256sum * > checksums.txt + - name: Attest build provenance + uses: actions/attest-build-provenance@v2 + with: + subject-path: assets/* + - name: Slice CHANGELOG section for the release notes + shell: bash + run: | + # Measured by the build leg, inside the image that produced the + # binary, over the appended payload -- see that step. It cannot be + # measured HERE: `readelf -V` on a onefile reads only the vendored + # bootloader (a container-invariant GLIBC_2.14), and the payload is + # inside the archive where the outer ELF's .gnu.version_r cannot see + # it. `|| true` because this step runs under `bash -eo pipefail`, + # where a failing substitution would abort before the guard below + # could ever print its reason. + GLIBC_FLOOR="$(cat meta/glibc-floor.txt 2>/dev/null || true)" + if [ -z "$GLIBC_FLOOR" ]; then + echo "::error::meta/glibc-floor.txt is missing or empty -- the Linux build leg did not report a measured floor, and the notes must not state one nothing measured." + exit 1 + fi + echo "measured glibc floor of the published Linux asset: ${GLIBC_FLOOR}" + + # The tag is vX.Y.Z; the CHANGELOG header is `## [X.Y.Z]` (no v-prefix, + # em-dash date). Extract that one section as the GitHub Release body. + VERSION="${GITHUB_REF_NAME#v}" + python3 - "$VERSION" <<'PY' > release_notes.md + import sys, re + version = sys.argv[1] + out, capturing, found = [], False, False + for line in open("CHANGELOG.md", encoding="utf-8"): + if re.match(rf"^## \[{re.escape(version)}\]", line): + capturing = found = True + continue + if capturing and line.startswith("## ["): + break + if capturing: + out.append(line) + body = "".join(out).strip() + # #212: this used to `print(body if body else f"See CHANGELOG.md for + # {version}.")` and exit 0, so a tag whose section was never written + # published a release whose entire body was that one sentence -- and + # nothing anywhere said so. The failure mode is not hypothetical: the + # version bump renames `## [Unreleased]` to `## [X.Y.Z]`, and a bump + # that edits the version files but forgets the CHANGELOG header leaves + # exactly this state. Checked against dev before this change: a v0.4.1 + # tag would have found no section and shipped the stub. + # + # A release with no notes is not a degraded release, it is a broken + # one -- the notes are the only human-readable record of what changed, + # and the tag is immutable once pushed. Fail before publishing, not + # after. `shell: bash` runs with `-eo pipefail`, so a non-zero exit + # here stops the job. + if not found: + sys.exit( + f"::error::CHANGELOG.md has no `## [{version}]` section, so this " + f"release would publish with an empty body. The version bump " + f"renames `## [Unreleased]` to `## [{version}] -- `; that " + f"edit is missing. Fix CHANGELOG.md on the release branch, then " + f"re-tag." + ) + if not body: + sys.exit( + f"::error::CHANGELOG.md's `## [{version}]` section is empty. A " + f"release body has to say what changed; write the section, then " + f"re-tag." + ) + print(body) + PY + cat >> release_notes.md <<'NOTES' + + ## Release assets + + Four archives, each a PyInstaller --onedir freeze of the Python + `tan` (tan-cli#349 -- was a single-file --onefile freeze; --onedir + fixes a 13-19s macOS startup regression caused by --onefile + re-extracting its runtime on every invocation). Unpack the archive + and run the `tan`/`tan.exe` inside; `install.sh`/`install.ps1` do + this for you. + + - `tan-x86_64-pc-windows-msvc.zip` -- Windows x64 + - `tan-x86_64-apple-darwin.tar.gz` / `tan-aarch64-apple-darwin.tar.gz` -- macOS + - `tan-x86_64-unknown-linux-gnu.tar.gz` -- Linux x64, frozen on Debian 11. + It requires **__GLIBC_FLOOR__** or newer -- measured from the + binary's own bundled payload at build time, not assumed from the + build image. Debian 11+ / Ubuntu 20.04+ / RHEL 9+ are comfortably + above it. + + There is no arm64 Windows and no arm64 Linux asset in this release, + and no `-musl` asset. A frozen binary has to be built on the + architecture it runs on, and this release builds on four runners; if + you need an arm64 Linux or arm64 Windows `tan`, install from source + (`pip install ./python`) and say so on the issue tracker. + + - Every archive + `checksums.txt` carries a GitHub build-provenance + attestation. Verify with: + `gh attestation verify --repo alplabai/tan-cli` + NOTES + # The heredoc above is quoted (no expansion -- it is full of backticks + # that would otherwise be command substitution), so the measured floor + # is substituted here instead. + sed -i "s/__GLIBC_FLOOR__/${GLIBC_FLOOR}/" release_notes.md + echo "----- release_notes.md -----"; cat release_notes.md + # A SemVer pre-release tag carries a hyphen in its version (`v0.4.0-rc1`); + # a real release never does. Both flags are derived from that one fact so + # they cannot disagree with each other or with the tag. + # + # This is load-bearing, not hygiene. Both installers resolve what `latest` + # means through GitHub, and GitHub excludes a release from `latest` ONLY + # when it is marked `prerelease`. Publishing an rc without these flags + # therefore hands it to every customer running the documented install + # command. Neither flag was set before, so the classification rested + # entirely on the action's default -- an unacceptable place for that blast + # radius to live. + # + # The two scripts ask two different endpoints -- `install.sh` follows the + # `/releases/latest` redirect, `install.ps1` reads the API's `tag_name` + # (see each script for why its host needs that one). Both exclude + # prereleases on the same flag, so they agree; verified against this repo + # with v0.4.0 marked prerelease, where both resolve `latest` to v0.3.1 + # rather than to the higher version number. + # + # `make_latest` is spelled as an explicit "true"/"false" string because the + # action takes a string, not a boolean. + - name: publish release + uses: softprops/action-gh-release@v2 + with: + files: assets/* + fail_on_unmatched_files: true + body_path: release_notes.md + prerelease: ${{ contains(github.ref_name, '-') }} + make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }} + + # The crates.io job is GONE (tan-cli#271). It published `alp-tan-cli` from + # `crates/`, and no asset in this release comes from `crates/` any more -- the + # four binaries above are PyInstaller freezes of `python/`. Keeping it behind + # a false `if:` would have left a `cargo publish` in the release path shipping + # a different program under the same name; deleting it is the only version of + # "must not run" that is actually true. The PyPI equivalent (`pip install + # alp-tan`, the name python/pyproject.toml already reserves) is a separate + # decision and deliberately not smuggled in here. + + # npm shim — `npm i -g @alplabai/tan` / `npx @alplabai/tan`. One release tag + # scheme (v*), one workflow. Gated on NPM_TOKEN; on a FINAL tag a missing + # token FAILS rather than skipping, mirroring the crates.io job above (#151). + # + # A pre-release tag is skipped here too, and this path was the sharpest of the + # three: `npm publish` below passes no `--tag`, so npm defaults the release to + # the `latest` dist-tag. An unguarded rc would therefore become plain + # `npm i -g @alplabai/tan` for every consumer -- and npm unpublish is far more + # restricted than a crates.io yank (72-hour window, refused outright once + # anything depends on it). + # + # The relaxation, when we want an rc installable: publish it with + # `--tag next` so `npm i @alplabai/tan@next` reaches it while `latest` stays + # on the last real release. Skipping is the smaller change and keeps the rc + # fully retractable, which is the point of cutting one. + publish_npm: + name: publish · npm shim + needs: release + if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-') }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + defaults: + run: + working-directory: npm-shim + # What this job actually DID, for `release_gate` to compare against what was + # declared (#237). Deliberately OBSERVED rather than re-derived from + # `NPM_PUBLISH_ENABLED`: re-reading the declaration would make the gate + # circular — it would assert the declaration equals itself and pass even if + # the job had done the opposite. `published=true` is written only AFTER + # `npm publish` returns, and `false` only by the branch that declines. + # + # Two step ids because exactly one of them runs; a skipped step contributes + # nothing, so the `||` picks whichever fired. + outputs: + published: ${{ steps.published.outputs.published || steps.declined.outputs.published }} + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + # Publishing is OPT-IN, off by default. Set the repository VARIABLE + # `TAN_NPM_PUBLISH` to `true` to arm it (Settings -> Secrets and variables + # -> Actions -> Variables). A variable, not a secret: the value is not + # sensitive, and a secret that gates behaviour is invisible in the UI. + # + # WHY off (#233): v0.4.1's publish failed with `npm error code EOTP` -- + # `NPM_TOKEN` is a classic/publish token on a 2FA account, so `npm publish` + # demands an interactive one-time password no CI run can answer. Only an + # npm AUTOMATION (or granular) token is exempt. That is an account-side + # fix, so the failure recurs on every final tag until it is made. + # + # A permanently-red job on the release workflow is worse than no job. It is + # the mirror of a gate that cannot fail: a job that can only fail teaches + # everyone to stop reading the one board where ignoring red is least + # affordable. So this is gated OFF rather than left red -- and the OFF path + # is LOUD (see `npm publishing is disabled` below), because the other + # failure this repo spent the week removing is a channel that quietly + # reports success while shipping nothing (#151). + NPM_PUBLISH_ENABLED: ${{ vars.TAN_NPM_PUBLISH == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: https://registry.npmjs.org + + # Runs whether or not publishing is armed. The shim is still a shipped + # artefact of this repo, and a shim that stops packing is worth catching + # while it is cheap rather than on the day the token is fixed. + - name: Pack (smoke — confirm the shim packs with its bin + postinstall) + run: | + npm pack --dry-run + node -e "const p=require('./package.json'); if(!p.bin||!p.bin.tan) throw new Error('shim missing bin.tan')" + + # The OFF path, and it says so out loud in the run summary rather than + # passing silently. Exits 0: not publishing is the DECISION here, not a + # failure, and the release itself is unaffected. + - name: npm publishing is disabled + id: declined + if: ${{ env.NPM_PUBLISH_ENABLED != 'true' }} + run: | + echo "published=false" >> "$GITHUB_OUTPUT" + { + echo "### npm: NOT PUBLISHED — deliberately disabled" + echo + echo "\`@alplabai/tan@${GITHUB_REF_NAME#v}\` was **not** published. The shim packed cleanly; publishing is gated off." + echo + echo "Reason: the configured \`NPM_TOKEN\` requires an interactive one-time password (\`npm error code EOTP\`), which no CI run can supply. It needs replacing with an npm **automation** token — see [#233](https://github.com/alplabai/tan-cli/issues/233)." + echo + echo "To arm this job once that is done, set the repository variable \`TAN_NPM_PUBLISH\` to \`true\`." + } >> "$GITHUB_STEP_SUMMARY" + echo "::notice::npm publish is gated off (TAN_NPM_PUBLISH is not 'true'). @alplabai/tan was NOT published for this tag; see #233." + + # Same rule as the crates.io job, and for the same reason: this job only + # runs on a final tag, so a missing token means the release advertises + # `npm i -g @alplabai/tan` for a package that does not exist. v0.4.0 did + # (#151). The pack smoke above still runs first, so a genuinely broken + # shim is reported as a broken shim rather than as a missing secret. + # The three ARMED steps below all carry the same gate. Left individually + # conditional rather than split into a second job, so the token refusal, + # the publish and the outcome record stay next to the pack smoke they + # belong with. + - name: Refuse to "publish" with no token + if: ${{ env.NPM_PUBLISH_ENABLED == 'true' && env.NPM_TOKEN == '' }} + run: | + echo "### npm: NOT PUBLISHED" >> "$GITHUB_STEP_SUMMARY" + echo "\`NPM_TOKEN\` is not set, so \`npm i -g @alplabai/tan\` will not resolve for this release. The shim packed cleanly." >> "$GITHUB_STEP_SUMMARY" + echo "::error::NPM_TOKEN is not set, but TAN_NPM_PUBLISH is 'true'. A FINAL release must not report a successful npm publish it did not perform (#151). Add the secret, or unset TAN_NPM_PUBLISH." + exit 1 + + - name: Publish to npm + if: ${{ env.NPM_PUBLISH_ENABLED == 'true' }} + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Reached only if `npm publish` above returned 0 -- a failure fails the job + # before this runs, so `published=true` is an observation, not a claim. + - name: Record the npm outcome + id: published + if: ${{ env.NPM_PUBLISH_ENABLED == 'true' }} + run: | + echo "published=true" >> "$GITHUB_OUTPUT" + echo "### npm: published \`@alplabai/tan@${GITHUB_REF_NAME#v}\`" >> "$GITHUB_STEP_SUMMARY" + + # --------------------------------------------------------------------------- + # #237: a tag must not be able to skip its own publishes silently. + # + # v0.4.1 proved it could. One flaky unit test failed `gates`, so `build`, + # `release` and both publish jobs were marked SKIPPED, and the tag existed -- + # immutably -- with no Release, no assets, and `releases/latest` still pointing + # at the previous version. The only signal was a red check on a run nobody was + # necessarily watching. `skipped` is the right semantic for a conditional job + # and the wrong one for the publish leg of a tag, because a tag is a commitment: + # the version number is spent and `install.sh` resolves it the moment a Release + # appears. + # + # This is NOT an exemption list. An exemption list grows -- npm today, some + # future channel tomorrow -- and every addition is a place the invariant quietly + # stops covering something. The invariant is DECLARATION-RELATIVE instead, the + # same mechanism #219 used for issue codes: declare the intent, then assert the + # outcome against the declaration. + # + # 1. HARD, unconditional: a pushed `v*` tag MUST produce a Release with its + # assets. Nothing legitimately skips that, rc or final. + # 2. PER CHANNEL: the outcome must match the channel's declared intent. + # crates.io has no gate variable, so its intent is "always". npm's intent + # is `TAN_NPM_PUBLISH`; unset means it must decline and exit 0, armed means + # it must publish or fail. + # + # So a disarmed npm channel is not an exemption -- it SATISFIES the invariant, + # because declaration and outcome agree. And arming it changes the declaration, + # after which this gate demands a publish with no edit here. That is also why + # this cannot re-create the permanently-red board #239 removed. + release_gate: + name: release outcome matches intent + needs: [release, publish_npm] + # `always()`, or a skipped dependency skips the gate too and the whole point + # is lost. Tag-only: this workflow has no other trigger today, but the guard + # makes the scope explicit rather than inherited. + if: ${{ always() && startsWith(github.ref, 'refs/tags/') }} + runs-on: ubuntu-latest + steps: + - name: assert the release and every channel matched its declared intent + shell: bash + env: + RELEASE_RESULT: ${{ needs.release.result }} + NPM_RESULT: ${{ needs.publish_npm.result }} + NPM_PUBLISHED: ${{ needs.publish_npm.outputs.published }} + NPM_DECLARED: ${{ vars.TAN_NPM_PUBLISH == 'true' }} + run: | + set -euo pipefail + fail=0 + note() { echo "$1" >> "$GITHUB_STEP_SUMMARY"; } + bad() { echo "::error::$1"; note "- FAIL: $1"; fail=1; } + + note "### Release outcome vs declared intent" + note "" + note "| leg | declared | result | published |" + note "|---|---|---|---|" + note "| release | always | \`${RELEASE_RESULT}\` | - |" + note "| npm | \`TAN_NPM_PUBLISH=${NPM_DECLARED}\` | \`${NPM_RESULT}\` | \`${NPM_PUBLISHED:-}\` |" + note "" + + # 1. Unconditional. A tag with no Release behind it is the v0.4.1 state. + if [ "$RELEASE_RESULT" != "success" ]; then + bad "no GitHub Release was published for ${GITHUB_REF_NAME} (release job: ${RELEASE_RESULT}). A pushed tag must always produce a Release with its assets; the tag is now spent with nothing behind it." + fi + + # A prerelease tag (a hyphen in the version) declares that the registry + # channels do not run at all, which is what their own `if:` encodes. Both + # being `skipped` is then the CORRECT outcome, so stop here. + case "${GITHUB_REF_NAME}" in + *-*) + note "Prerelease tag: registry channels declared not-run, both \`skipped\` as expected." + [ "$fail" -eq 0 ] || exit 1 + echo "prerelease: release asserted, registry channels correctly skipped." + exit 0 + ;; + esac + + # 2b. npm against its declaration. The job must not have FAILED either + # way -- a disarmed channel still has to exit 0 having said so. + if [ "$NPM_RESULT" != "success" ]; then + bad "the npm job did not complete cleanly (job: ${NPM_RESULT}). Disarmed it must report NOT PUBLISHED and exit 0; armed it must publish." + # The job succeeded, so `published` MUST be one of the two values it + # writes. Anything else means the outputs wiring between `publish_npm` + # and this gate is broken, and the comparisons below would be reading + # nothing. + # + # This is the one hole `always()` + a declaration check cannot close by + # itself. An unresolvable `needs.publish_npm.outputs.published` -- + # renamed step id, removed `outputs:` block, a context GitHub does not + # populate -- evaluates to the EMPTY STRING; it does not error. And '' + # satisfies `!= "true"`, which is the disarmed-expected branch, which is + # the state tan is in today. So a broken wiring would pass GREEN on every + # disarmed tag and only surface at ARMING time, reported as "npm did not + # publish" when npm may have published fine and the gate is describing + # its own broken input. Arming is the moment this gate most needs to be + # trustworthy; it must not be the moment it starts lying. + # + # A gate that reads an empty input and reports "matched declared intent" + # is #151's silent success one level up, which is what this job exists to + # remove. + # + # STRUCTURAL PRECONDITION -- do not merge the publish jobs. + # + # "Empty means the wiring is broken" is only true because each channel is + # a SEPARATE JOB with its own `needs: release`. A failed `release` or a + # failed `publish_crates` cannot blank npm's self-report, because npm's + # job either ran and reported, or was skipped and is caught by the + # `NPM_RESULT` check above. So an empty `published` from a job that + # reports `success` really does mean the outputs wiring is broken, and + # nothing else. + # + # Collapse the channels into ordered STEPS of one job -- the obvious + # refactor, for speed or for a shared checkout -- and that stops holding: + # an earlier step failing aborts the job before the later channel's + # reporting step runs, its output comes back empty for a reason that has + # nothing to do with wiring, and this check fires a FALSE "wiring is + # broken" beside the true error. + # + # Not hypothetical. alp-sdk-vscode ported this gate, merged its four + # publish legs into one job's steps, and hit exactly that: a Marketplace + # failure aborted before the Open VSX steps, and their port reported a + # broken wiring next to the real failure -- misdiagnosing the one scenario + # the gate was built for. + # + # There is no test that catches this: the assertion still passes on every + # green tag, and only lies on a failing one. Hence the comment. + # + # Kept in the SAME elif chain rather than a separate `case`, so a broken + # wiring reports exactly one cause: a garbage value would otherwise also + # trip a declaration comparison it has nothing to compare against. + elif [ "$NPM_PUBLISHED" != "true" ] && [ "$NPM_PUBLISHED" != "false" ]; then + bad "the npm job reported published='${NPM_PUBLISHED:-}', which is neither true nor false. The outputs wiring between publish_npm and this gate is broken, so the declaration checks are comparing against nothing. Check publish_npm's \`outputs:\` block and the \`declined\`/\`published\` step ids." + elif [ "$NPM_DECLARED" = "true" ] && [ "$NPM_PUBLISHED" != "true" ]; then + bad "TAN_NPM_PUBLISH is 'true' but the npm job did not publish (published=${NPM_PUBLISHED:-}). An armed channel that ships nothing is the silent-success failure #151 removed." + elif [ "$NPM_DECLARED" != "true" ] && [ "$NPM_PUBLISHED" = "true" ]; then + bad "npm published while TAN_NPM_PUBLISH is not 'true'. The channel shipped something nobody declared." + fi + + if [ "$fail" -ne 0 ]; then + note "" + note "This tag did NOT ship as declared. See the annotations above." + exit 1 + fi + note "All legs matched their declared intent." echo "release + every channel matched its declared intent." \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 467fc0a2..14092cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,116 @@ All notable changes to `tan` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer](https://semver.org/). +## [0.6.0] — Unreleased + +### Added + +- **All seven formerly-deferred verbs are now real commands** (`scaffold`, + `completion`, `diff`, `pinmux`, `inspect`, `trace`, `support-bundle` + — tan-cli#260, CLOSED). Each used to be a uniform stub — exit 1, one shared + `cli.command-deferred` issue, naming this tracking issue — registered only + so the command resolved instead of falling through to Click's exit-2 + unknown-command error. `tan/commands/deferred_cmd.py` keeps only the two + constants (`DEFERRED_ISSUE_CODE`, `DEFERRED_ISSUE_URL`) and the context + settings `tan build`'s own still-deferred *flags* (`--plan`, `--target`, + …) reuse; the stub factory and its `DEFERRED_VERBS` tuple are gone. + `tan/cli.py`'s `_HONOURS_ROOT_FORMAT` now spells the seven names directly + rather than deriving them from that removed tuple. +- **`contract/issue-codes.json` gained 49 `"reserved"` entries** for codes + the seven newly-real verbs (and this wave's `doctor --fix` consent-gate + work) already emitted with nowhere registered to bind to: + `diff.board-yaml-missing`/`.internal-failure`/`.pyyaml-unavailable`/ + `.schema-violation`; `pinmux.internal-failure`; `trace.sdk-root-unresolved`/ + `.board-yaml-missing`/`.internal-failure`; `support-bundle.` + (20 entries, mirroring the existing `doctor.` family verbatim, + since `support-bundle`'s doctor section reuses `doctor_cmd`'s `Check` + objects unchanged); `doctor.fix-needs-sudo`/`.fix-installed`/ + `.fix-spawn-failed`/`.fix-failed`/`.fix-timed-out`/`.fix-suppressed`; + `scaffold.name-required`/`.cancelled`/`.invalid-template`/`.invalid-name`/ + `.internal-failure`; `debug-config.gdbserver-address-unresolved`; and 9 + `renode.sim-*`/`renode.expect-ignored` codes from the `--sim-mode` gateway + (tan-cli#77) that had never been registered either. None of these were + reachable before this wave — `diff.*` had ZERO entries of any kind — so + none is a wire break; every one is `"reserved"`/`"consumer": "none"`, + costing nothing to rename later. +- **`tan flash` can auto-sign an Alif Ensemble slot0 ATOC via SETOOLS** + (tan-cli#353, #365-#369, #373; `tan/core/setools.py`, new). Flow D + (`alif_mram_jlink`, J-Link straight over SWD, no SE-UART) needs a SIGNED + ATOC from Alif's own `app-gen-toc` step; a fresh AEN801 manifest's + `flash_args` carries only `jlink_flash_device` (measured on real silicon, + e1m-aen-evk-01/E8 AE822), so before this every customer had to sign by + hand outside `tan` and paste the result back in. `tan flash` now drives + `app-gen-toc` for you against a SETOOLS install you already have — three + precedence-ordered sources (new `--setools-dir` flag, `SETOOLS_DIR`, or + the least-durable `flash_args.setools_dir`, tan-cli#368), never a + filesystem search, and never under `--dry-run`. See + [`docs/setools.md`](docs/setools.md). SETOOLS itself is license-gated and + neither `tan` nor alp-sdk redistributes it. + +### Changed + +- **BREAKING: `tan validate`'s not-yet-ported spawn path now exits 2 + (`VALIDATION_FAILURE`), not 1 (`RUNTIME_FAILURE`)** (tan-cli#262, TAKEN). + Before this release, a `board.yaml` present with an unresolvable SDK (or, + once the real validator spawn path lands, any post-spawn verdict failure) + answered `validate.spawn-not-implemented` at exit 1 — indistinguishable + from a genuine tan crash. Measured against the oracle + (`target/debug/tan.exe`): every guard-level `validate` refusal already + exits 2, and exit 1 there is reserved for the ONE case a spawned validator + returns an unmappable status — this port had flattened that distinction. + "The validator could not produce a verdict" is now treated as the + validator's own verdict everywhere, at exit 2, matching the guard cases. + **Who must act:** any CI step that greps this exit code and branches on + `-eq 1` specifically (rather than "non-zero") now sees 2 instead and will + silently stop matching; `alp-sdk-vscode` renders exit 2 as severity + "warning" and exit 1 as "error", so a consumer keyed on the old code will + now show a genuine validation gap as a warning rather than an error until + it is updated to read exit 2. A real `tan`-side crash (an unreadable + `board.yaml`, an unexpected internal exception) is unaffected and keeps + exit 5. + +### Fixed + +- **BLOCKER: the Flow D SETOOLS auto-sign's own soft-failure guard could + destroy a prior sign record instead of only detecting a fresh one** + (tan-cli#373, regression in #365's own fix). `app-package-map.txt` is + APPEND-mode — the accumulated sign record for the whole SETOOLS install, + including hand-runs done outside `tan`, per `flash-jlink.sh`/ + `flash-jlink-mramxip.sh`/`flash-update-log-dual.sh` — but the guard + `os.remove`d it before every sign to detect an `app-gen-toc` exiting 0 + without actually writing. On a manifest with two Flow D entries where one + pointed its own `flash_args.atoc_map` at that same file, the other + entry's auto-sign wiped it first: `app-gen-toc` recreated it holding only + the SECOND entry's block, and the first entry's `atoc_address` resolved + to the second entry's address — a mismatched ATOC burned into on-die + MRAM, recoverable only by re-provisioning over SE-UART. Replaced with a + size+mtime snapshot taken before the spawn: an append changes both, so an + unchanged snapshot after a zero exit is the same soft-failure signal, + without deleting anything. +- **`tan flash --dry-run`'s SETOOLS preview still bypassed most of Flow D's + own validation** (tan-cli#373, #366 narrowed not closed). A `--dry-run` + whose `SETOOLS_DIR` resolved reported `ok:true` for a manifest with e.g. a + quoted `jlink_speed`, because that check lived only inside + `plan_alif_mram_jlink`, unreached from the preview's early return — and on + a REAL run the SETOOLS auto-sign (writing into the customer's install) + happened before that refusal was ever reached. `jlink_speed`/`confirm` + are now validated in `validate_flow_d_shape`, the one function both the + preview and the real plan-builder call before either can proceed. +- **The wrong-DP-ID preflight remediation had replaced the wiring/ + `jlink_serial` advice for three OTHER banners it does not apply to** + (tan-cli#369 regression). That fallback is shared by four cases — a + genuinely different SW-DP ID answering, an unrecognised banner, an + unplugged-SWD-ribbon `Cannot connect to target.`, and a refused + `jlink_serial`'s `Cannot connect to J-Link.` — but #369's rewrite gave all + four the cloned-serial explanation, silently deleting the original + "check the probe selection (jlink_serial) and the wiring" sentence + (tan-cli#353) for the three where `jlink_serial` genuinely IS the fix. Now + branched on whether a DP ID was actually read. +- **`is_elf_artefact` only accepted `.elf`, narrowing #367(a)'s own + three-shape decision** (no extension, `.elf`, `.out`) with nothing + flagging it. `output_artefact: app` (no extension) or `app.out` now + resolves to its same-stem sibling `.bin` again, same as `zephyr.elf` does. + ## [0.5.0-rc4] — 2026-08-02 *Everything below was found by running the published `v0.5.0-rc3` binary as a @@ -571,12 +681,16 @@ else installs by hand. compatibility fix. #262 is re-scoped to the one case that is a genuine v0.6.0 decision: `validate.failed` after a real spawn. - Still divergent, deliberately, and tracked in #262: `board.yaml` present but - no SDK root, where the oracle says 2 `validate.sdk-root-unresolved` and this - port says 1 `validate.spawn-not-implemented`. Closing it needs the real spawn - path. The two genuine internal failures in the same file (an unreadable - `board.yaml`, an unexpected exception inside the offline structural checker) - are unchanged at exit 5, matching the oracle's offline path exactly. + **Corrected 2026-08 (v0.6.0): the paragraph this replaced claimed + `validate.spawn-not-implemented` was still exit 1 at this port's rc1 tag. + That was true when written and is not true of the current tree — #262 was + decided and TAKEN in v0.6.0 (see that section below for the full BREAKING + change): `validate.spawn-not-implemented` now also emits exit 2 + (`VALIDATION_FAILURE`), the same code the guard cases above already used, + closing the divergence this paragraph used to describe as open.** The two + genuine internal failures in the same file (an unreadable `board.yaml`, an + unexpected exception inside the offline structural checker) are unchanged + at exit 5, matching the oracle's offline path exactly. - **`tan sdk install` / `tan sdk switch` refused at exit 5 (`InternalFailure`), telling CI and the extension that tan had crashed.** Neither is ported; that diff --git a/README.md b/README.md index c7b45c45..485aaee3 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,27 @@ executes it — it is the single executor and the user command surface for building, flashing, and inspecting Alp Lab E1M / E1M-X firmware. -`bootstrap` / `build` / `run` / `size` / `image` / `flash` / `clean` / `renode` -run directly in `tan` — `bootstrap` included, so there is no `bash` dependency -and native Windows is a first-class host. Only `migrate` / `lock` / `quality` -still forward to `west alp-*`, and -`model` / `monitor` / `new-som` / `faultdecode` to the SDK `alp` CLI. Licensed -**Apache-2.0** (see [`LICENSE`](LICENSE); the SPDX identifier is also set in each -`Cargo.toml` and source header). +`bootstrap` / `build` / `run` / `size` / `image` / `flash` / `clean` / `renode` / +`monitor` run directly in `tan` — `bootstrap` included, so there is no `bash` +dependency and native Windows is a first-class host. So does the rest of the +surface: `model`, `new-som`, and `faultdecode` are native ports now +(tan-cli#253, #254, #256), not forwards to the SDK's `alp` CLI, and the seven +verbs that used to stub out (`scaffold`, `completion`, `diff`, `pinmux`, +`inspect`, `trace`, `support-bundle`) are real too (tan-cli#260, #257). Only +`migrate` / `lock` / `quality` still forward, to `west alp-*`. Licensed +**Apache-2.0** (see [`LICENSE`](LICENSE); the SPDX identifier is also set in +each `Cargo.toml` and source header). ## Install -Every version tag publishes a raw, uncompressed binary per platform. +**From `v0.5.0`** every version tag publishes one archive per platform (`.zip` +on Windows, `.tar.gz` on Unix) — a PyInstaller `--onedir` freeze, not a raw +binary (tan-cli#349). **`v0.5.0` is not cut yet**: every tag published so far +ships a raw binary instead, including `v0.4.1` (which is what `latest` resolves +to today) and the `v0.5.0-rc4` pre-release. The install scripts read which shape +a release publishes off that release's own `checksums.txt` and install either +one (tan-cli#356), so the commands below work on both sides of that transition — +you do not need to know which tag you are on. ### Automatic (recommended) @@ -69,13 +79,22 @@ confident `OK`. Set the variable to an explicit `vX.Y.Z` from the different one. (`latest` skips pre-releases, so it is not always the highest version number.) +> **Check the asset name against the tag you picked.** The snippets below are +> written for the archive shape, i.e. `v0.5.0` and later. A pre-`v0.5.0` tag — +> `v0.4.1`, `v0.5.0-rc4`, anything else published so far — names the same triple +> with **no extension** (`.exe` on Windows) and that file **is** the executable: +> drop the `tar -xzf` / `Expand-Archive` step and install the downloaded file +> itself. Either way, `/checksums.txt` lists exactly what that release +> publishes, which is where the install scripts get the answer (tan-cli#356) — +> so it is also the fastest way to check by hand. + **Linux / macOS** ```sh # Resolve latest ONCE (or set TAG=vX.Y.Z yourself), same redirect install.sh follows. TAG=$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ https://github.com/alplabai/tan-cli/releases/latest | sed 's#.*/tag/##') -ASSET=tan-x86_64-unknown-linux-gnu # swap for your platform; gnu, not musl -- see docs/release-contract.md's glibc floor (a PyInstaller freeze can't produce a static musl artefact; the floor is measured per-release, published in that release's notes) +ASSET=tan-x86_64-unknown-linux-gnu.tar.gz # swap for your platform; gnu, not musl -- see docs/release-contract.md's glibc floor (a PyInstaller freeze can't produce a static musl artefact; the floor is measured per-release, published in that release's notes) BASE=https://github.com/alplabai/tan-cli/releases/download/$TAG # macOS has shasum, not sha256sum -- pick whichever is present. @@ -88,8 +107,10 @@ curl -fsSL -o "$d/checksums.txt" "$BASE/checksums.txt" && line=$(awk -v a="$ASSET" '$2 == a' "$d/checksums.txt") && [ -n "$line" ] && printf '%s\n' "$line" | (cd "$d" && $SHA -c -) && -chmod +x "$d/$ASSET" && -sudo mv "$d/$ASSET" /usr/local/bin/tan && +tar -xzf "$d/$ASSET" -C "$d" && # unpacks to $d/tan/{tan,_internal/} +chmod +x "$d/tan/tan" && # tar preserves the bit already; cheap insurance +sudo mv "$d/tan" /usr/local/lib/tan-cli && +sudo ln -sf /usr/local/lib/tan-cli/tan /usr/local/bin/tan && tan --version ``` @@ -110,7 +131,7 @@ $ErrorActionPreference = 'Stop' # Resolve latest ONCE (or set $Tag = 'vX.Y.Z'), same API field install.ps1 reads. $Tag = (Invoke-RestMethod -Uri 'https://api.github.com/repos/alplabai/tan-cli/releases/latest' -UseBasicParsing).tag_name -$Asset = 'tan-x86_64-pc-windows-msvc.exe' +$Asset = 'tan-x86_64-pc-windows-msvc.zip' $Base = "https://github.com/alplabai/tan-cli/releases/download/$Tag" # Fresh dir, never the destination: a bad binary written straight to tan.exe has @@ -132,11 +153,13 @@ $got = (Get-FileHash -LiteralPath "$d\$Asset" -Algorithm SHA256).Hash.ToLower() if (-not $want) { throw "$Asset is not listed in $Tag's checksums.txt -- the release is incomplete. Nothing installed." } if ($got -ne $want) { throw "SHA256 MISMATCH for $Asset ($Tag): expected $want, got $got. Nothing installed." } -# Only now put it in place. This is where install.ps1 puts it. +# Only now unpack it. $Asset is an archive (tan\ containing tan.exe + _internal\), +# not a raw exe -- this is where install.ps1 puts it, minus its launcher script. $dest = "$env:LOCALAPPDATA\Programs\tan" New-Item -ItemType Directory -Force -Path $dest | Out-Null -Move-Item -LiteralPath "$d\$Asset" -Destination "$dest\tan.exe" -Force -& "$dest\tan.exe" --version # add $dest to your user PATH to run `tan` from a new shell +Expand-Archive -LiteralPath "$d\$Asset" -DestinationPath $d -Force +Move-Item -LiteralPath "$d\tan" -Destination $dest -Force +& "$dest\tan\tan.exe" --version # add $dest\tan to your user PATH to run `tan` from a new shell ``` **Stronger, when you have [`gh`](https://cli.github.com/):** every asset — @@ -159,23 +182,27 @@ release says nothing about who built it. Run the digest check always; add the attestation when `gh` is available. Details in [`docs/release-contract.md`](docs/release-contract.md). -**From source** (Rust **1.86+**, edition 2024): +**From source** (Python **3.12+**) — the release assets are PyInstaller freezes +of this same tree (tan-cli#271): ```sh git clone https://github.com/alplabai/tan-cli && cd tan-cli -cargo install --path crates/tan-cli --locked +pip install ./python +tan --version ``` -### Package managers +`crates/` (the original Rust implementation, `cargo install --path +crates/tan-cli`) still builds and is still tested by CI, but it is a frozen +reference now — new features land only in `python/`, so building it produces +the stale, v0.4.1-era program under the same `tan` name. -**crates.io** — **works** as of `v0.4.1`. Rust **1.86+**, edition 2024. The -published crate is named `alp-tan-cli` (`tan`/`tan-cli` were already taken on -crates.io by an unrelated project); the installed binary is still `tan`: +### Package managers -```sh -cargo install alp-tan-cli --locked -tan --version -``` +**crates.io — do not advertise.** `cargo install alp-tan-cli` still resolves +(it worked as of `v0.4.1`), but the `publish · crates.io` job was deleted at +`v0.5.0` — the assets are no longer `cargo` builds, so publishing `alp-tan-cli` +would ship a different program under the same name (docs/release-contract.md). +Installing it today gets you the stale Rust CLI, not the current `tan`. **npm — does not resolve. Do not use these commands yet.** @@ -185,8 +212,9 @@ tan --version > `404 Not Found`. The v0.4.1 publish job failed with `npm error code EOTP` — > the configured `NPM_TOKEN` requires an interactive one-time password, which no > CI run can supply, so it needs replacing with an npm **automation** token -> ([#233](https://github.com/alplabai/tan-cli/issues/233)). Use a release binary -> above, the installer, or crates.io. +> ([#233](https://github.com/alplabai/tan-cli/issues/233)). Use a release +> archive above or the installer -- not crates.io (see Package managers +> above: that publish job is deleted too, and now installs the stale Rust CLI). > > The commands are recorded here only so the package naming does not change > under anyone later: @@ -227,22 +255,38 @@ tan run --flash # build, then run (host) or program (hardw `tan doctor` sanity-checks the host: build readiness (SDK, Zephyr workspace, west) alongside debug readiness for the selected target/server — the full check list runs unconditionally. `--build` is accepted for compatibility -(both `alp-sdk-vscode` call sites pass it) and changes nothing; `--fix` is -not yet accepted (tan-cli#295). `tan completion --shell zsh` is deferred in -this build (see Commands below) and exits 1 rather than emitting a -completion script. - -`bootstrap` runs natively on Linux, macOS and Windows and needs no `bash`; it -names the missing prerequisites rather than installing system packages itself. -The install commands come from the SDK's own `metadata/bootstrap.json` -(`prerequisites.install`, keyed per OS), not from a table `tan` carries — so -Windows prints the `winget install` line for a missing `git`/`cmake`/`python`/ -`ninja`, and the JSON envelope's `missingPrerequisites[].command` now carries -real `apt-get`/`brew` commands on Linux and macOS where it used to be `null` on +(both `alp-sdk-vscode` call sites pass it) and changes nothing. `--fix` +(ADR 0021, tan-cli#91) runs the SDK manifest's own install command for a +`hostPrerequisites` tool this host is missing, but only when the command needs +no elevation (Tier A — `winget`, `brew`, the small POSIX packages); anything +that needs `sudo` is refused and printed verbatim instead, never run — tan +never spawns `sudo` itself, since a password prompt has nowhere to go once +`--format json` has captured stdio, and would hang the process forever rather +than fail. `--fix` only ever acts in an interactive, non-CI, text-mode run: +`--ci`, `--non-interactive`, and `--format json` each disable it on their +own, and so does the same rule applied *unasked* — a piped or redirected +stdin/stderr (an automated run that never thought to pass one of those flags) +disables it exactly as hard, since a repair nobody watched happen is not +consent either way. It never re-checks its own work: this process already +read PATH once at start-up, so an install +landing after that is invisible to it — the honest outcome is "installed; +reopen your shell", not a claimed-verified pass. + +`bootstrap` itself runs natively on Linux, macOS and Windows and needs no +`bash`; it only ever *names* the missing prerequisites rather than installing +system packages itself — the executor lives in exactly one place, `doctor +--fix` above, never in `bootstrap` (ADR 0021: "build my project" must never +turn into running installs with no escape hatch). The install commands come +from the SDK's own `metadata/bootstrap.json` (`prerequisites.install`, keyed +per OS), not from a table `tan` carries — so Windows prints the +`winget install` line for a missing `git`/`cmake`/`python`/`ninja`, and the +JSON envelope's `missingPrerequisites[].command` now carries real +`apt-get`/`brew` commands on Linux and macOS where it used to be `null` on every POSIX host. The *printed* POSIX refusal line is deliberately unchanged — it stays `bootstrap.sh`'s verbatim, naming the tools and nothing else. An SDK too old to carry `prerequisites.install` falls back to the same commands, so no -host loses one. +host loses one. The rule across both commands is ADR 0021's: never *require* +copying a command — not "never print one". Zephyr and baremetal cores build on every host. Only a project whose cores are *all* Yocto is refused off Linux — a mixed board still bootstraps, with a @@ -268,27 +312,44 @@ foreign content either. | Area | Commands | | --- | --- | -| **Project** | `init` · `scaffold`† · `examples` · `explain` · `presets` · `pinmux`† | -| **Configure & verify** | `validate` · `generate` · `diff`† · `inspect`† · `trace`† · `doctor` · `debug-config` · `support-bundle`† · `kconfig` | -| **Build & run** (direct) | `build` · `run` · `flash` · `image` · `size` · `clean` · `renode` | -| **Environment** (direct) | `bootstrap` · `sdk` · `completion`† | -| **Forwarders** | `migrate` · `lock` · `quality` → `west alp-*`; `model` · `monitor` · `new-som` · `faultdecode` → `python -m alp_cli` | - -† Deferred to v0.6.0 (tan-cli#260): a working command in the Rust CLI -(tan-cli v0.4.1), but the Python port shipping this release stubs it — -exits 1, with the issue code `cli.command-deferred` in `--format json`; -text mode prints only the deferral message. - -`tan --help` for flags. Global flags apply to every command: +| **Project** | `init` · `scaffold` · `examples` · `explain` · `presets` · `pinmux` · `new-som` | +| **Configure & verify** | `validate` · `generate` · `diff` · `inspect` · `trace` · `doctor` · `debug-config` · `support-bundle` · `kconfig` · `faultdecode` | +| **Build & run** (direct) | `build` · `run` · `flash` · `image` · `size` · `clean` · `renode` · `monitor`‡ · `model` | +| **Environment** (direct) | `bootstrap` · `sdk` · `completion` | +| **Forwarders** | `migrate` · `lock` · `quality` → `west alp-*` | + +All 32 registered commands run directly in `tan` except the three forwarders +above. `scaffold`, `completion`, `diff`, `pinmux`, `inspect`, `trace`, and +`support-bundle` were stubs that exited 1 with the issue code +`cli.command-deferred` through the earlier RCs; they are ported now +(tan-cli#260, #257). `model`, `new-som`, and `faultdecode` were thin forwards +to `python -m alp_cli`; they are native, in-process implementations now +(tan-cli#253, #254, #256) — `python -m alp_cli` is no longer load-bearing for +any `tan` command. + +‡ `monitor` runs entirely in `tan` — it never resolves an alp-sdk checkout, +unlike `model`/`new-som`/`faultdecode` — but needs pyserial, which is an +*optional* dependency (`[project.optional-dependencies] monitor`, not +`dependencies`): `pip install "alp-tan[monitor]"` for a source install. A +release binary bundles pyserial at build time already. Without it, `tan +monitor` exits with the coded issue `monitor.pyserial-missing` naming the +fix — a binary built without that extra cannot pip-install its way out. + +`tan --help` for flags. Every command now parses the oracle's whole +global set (tan-cli#261, one shared `tan/core/global_flags.py`) — none of +`--project`, `--board-yaml`, `--sdk-root`, `--target`, `--all`, `--format`, +`--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--ci` raises "no +such option" anywhere any more; a command with no real use for one still +accepts and drops it rather than refusing it. | Flag | Effect | | --- | --- | | `--project ` | Project root (default: current directory). | | `--board-yaml ` | Explicit `board.yaml`, overriding project resolution. | | `--sdk-root ` | alp-sdk checkout to plan against. | +| `--target ` / `--all` | Parse on every command now instead of erroring, but the underlying behaviour is still deferred, not silently dropped: `tan build --target …`/`--all` refuses with the coded issue `cli.command-deferred` (tan-cli#260) naming it; every other command accepts and drops both with no effect. | | `--format json` | Machine-readable envelope instead of text. | -| `--non-interactive` | Not implemented in this build. Only `build --non-interactive` is even accepted, and it is itself deferred (tan-cli#260); no command changes behaviour for it yet. In the Rust CLI: never prompt, a command with a documented default takes it, one without fails naming the missing flag; applied unasked when stdin or stderr is not a terminal (#187). | -| `--ci` | Not implemented as a global flag in this build; `size --ci` is the one live exception, and only as an alias for `--no-color` there (`size` never prompts). In the Rust CLI: implies `--non-interactive` and disables color everywhere. | +| `--non-interactive` / `--ci` | Refuse to prompt or mutate the host without a human watching (`tan/core/consent.py`): a command with a documented default takes it, one without a default fails naming the missing flag. Applied *unasked* too — the same refusal fires when stdin or stderr is not a terminal (piped, redirected, a CI runner), not only when the flag is passed. `doctor --fix` (tan-cli#91) and `scaffold`'s prompt gate on this for real today; every other command accepts both flags without yet changing behaviour for them. | | `--quiet` / `--verbose` / `--no-color` | Output volume and styling. | `--format json` emits the stable envelope @@ -297,6 +358,13 @@ alp-sdk-vscode extension consumes (`sdk` is optional: present only when the command actually resolved an alp-sdk root). Text output is for humans and may change; the envelope is the API. +`tan flash`'s Flow D backend (`alif_mram_jlink`) can auto-sign an Alif +Ensemble slot0 ATOC for you via a SETOOLS install you already have on disk — +SETOOLS is license-gated and obtained directly from Alif, never redistributed +by `tan`. See [`docs/setools.md`](docs/setools.md) for the three ways to +point `tan` at it (`--setools-dir`, `SETOOLS_DIR`, `flash_args.setools_dir`, +in that precedence order) and what it does with it. + ## Where it sits (three repos, one executor) ``` diff --git a/contract/README.md b/contract/README.md index 301ac064..f17d16ff 100644 --- a/contract/README.md +++ b/contract/README.md @@ -1,273 +1,273 @@ - -# `contract/` — the JSON envelope drift gate - -The vscode extension drives `tan --format json` and hard-depends on -five things that nothing else in this repo pins: - -- the top-level envelope shape, `{ command, ok, exitCode, project, data, - issues }` (`crates/tan-cli/src/envelope.rs`); -- the exit-code contract — 0 success, 1 runtime, 2 validation, 3 write, 4 - doctor, 5 internal (`crates/tan-cli/src/exit.rs`); -- `tan --version`'s first stdout line, `tan MAJOR.MINOR.PATCH`; -- the **frozen issue codes** it matches with `===` (`issue-codes.json`, below); -- the **`data` field names** it reads with `?? []` fallbacks (below). - -`crates/tan-cli/tests/contract.rs` (run by `cargo test`, part of the normal -CI `test` job — no separate CI wiring needed) spawns the real, compiled `tan` -binary against the golden fixtures in `envelopes/` below and diffs the -result. A breaking wire-format change fails `cargo test` here instead of -being discovered later, silently, in the extension. - -This is a **Rust integration test, not a shell script** (unlike the retired -`cli-rs/contract/run.sh`): `cargo test` already runs it cross-platform (this -repo's CI test job matrixes ubuntu/windows/macos-latest — a bash harness -would need a second execution path on Windows CI runners for no benefit), -needs no new CI job, and gets `cargo`'s own binary discovery -(`CARGO_BIN_EXE_tan`) for free instead of a hand-rolled `target/debug/tan(.exe)` -path. - -## The frozen wire vocabulary (issue #106) - -`tan`'s envelope is a **versioned public contract**, not an implementation -detail. Two parts of it are matched by string on the consumer side, and both -matches **fail open**: an unrecognised issue code returns "no verdict" and a -missing `data` key falls back to `?? []`. The extension does not error, does -not log and does not warn — it silently skips the check or renders stale -data, with CI green on both sides. A rename here is therefore -indistinguishable from "no problem" until a customer hits it. - -**Do not fix a rename by loosening the consumer.** A prefix match on -`bootstrap.` would swallow codes the extension has no verdict for. The -contract belongs to whoever owns the envelope: this repo. - -### Exit codes (`crates/tan-cli/src/exit.rs`) - -| Code | Meaning | -|---|---| -| 0 | Success | -| 1 | Runtime failure (I/O, subprocess) | -| 2 | Validation failure (schema/semantic) | -| 3 | Write failure | -| 4 | `doctor` reported an unhealthy environment | -| 5 | Internal error (bug / unreachable state) | - -### Frozen issue codes (`issue-codes.json`) - -`issue-codes.json` is the single source; `contract.rs`'s `frozen_issue_codes` -gates it, and the release workflow publishes it. Renaming or removing a -`status: "frozen"` code is a **breaking wire change** — bump the CLI -MAJOR/MINOR, record it in `CHANGELOG.md`, and open the matching -alp-sdk-vscode issue. A `status: "reserved"` code has no consumer yet -(`consumer: "none"`): the gate still checks the spelling exists at the -emission site, but renaming or dropping it costs nothing on the wire — -promote it to `frozen` the moment a consumer actually binds to it. - -**Selection criterion for the table below: `frozen`/`retired` codes only** — -the ones where a rename or removal is the actual breaking wire change this -whole file exists to guard against. `reserved` codes are cheap to rename by -definition (nothing binds them with `===` yet), there are more of them than -usefully fit a table, and `issue-codes.json` is already their single source -with a full `consumerEffect` per entry — this table does not duplicate them. - -| Code | Status | Consumer effect if renamed | -|---|---|---| -| `bootstrap.windows-unsupported` (severity `error`) | retired | Emitted by tan ≤ v0.3.0 only. The consumer branch is permanent back-compat for anyone pinned to an old binary via `alpSdk.cliPath`, so the spelling is RESERVED and must never be re-used for a different verdict. | -| `bootstrap.yocto-host` (severity `error`) | frozen | A Yocto-only project is sent into a bootstrap that cannot work on this host. The mixed-board case reuses the suffix at severity `warning` and must stay a warning. | -| `bootstrap.prerequisites-missing` (severity `error`) | frozen | tan's own refusal is not recognised, so the extension spawns the real bootstrap terminal anyway and the customer watches the identical failure scroll past with the install guidance lost. | -| `presets.sdk-root-unresolved` (severity `warning`) | frozen | The New Project wizard silently falls back to its static catalogue, which carries no `cores`, so a **heterogeneous SoM scaffolds single-core with no IPC**. The reference part E1M-AEN801 is multi-core, so that is the default path. | -| `bootstrap.python-not-runnable` (severity `error`) | frozen | `python`/`python3` resolves on PATH but will not run (a Microsoft Store alias, or similar). Renamed, `alp-sdk-vscode`'s `prerequisitesMissingIssue` (`PREREQ_CODES`, `src/alpCli/service.ts`) no longer recognises tan's own refusal, so the extension spawns the real bootstrap terminal anyway and the customer watches the identical failure scroll past with the install guidance lost — same failure shape as `bootstrap.prerequisites-missing`. Carries no `missingPrerequisites[]` entry: a `{tool, command}` pair cannot represent "the Python you have will not run", so the fix travels only in `issues[].message`. | -| `bootstrap.python-too-old` (severity `error`) | frozen | The resolved Python is below the SDK tooling's floor (currently >= 3.10). Same consumer and the same failure shape as `bootstrap.python-not-runnable`; also tool-less. | - -`bootstrap.prerequisites-missing`, `bootstrap.python-not-runnable` and -`bootstrap.python-too-old` are the three codes `alp-sdk-vscode`'s -`prerequisitesMissingIssue` matches (`PREREQ_CODES`, a `Set` matched with -`.has()` — equivalent to `===` for this purpose) to stop it spawning a real -bootstrap that has already been refused. The latter two carry no missing -TOOL at all, so `missingPrerequisites[]` is always empty for them; the fix -travels only in `issues[].message` (see -`crates/tan-core/src/bootstrap/prerequisites.rs`). - -Every other registered code is `reserved` — no consumer binds any of them -yet, so renaming or dropping one costs nothing on the wire, and none is -tabled above per the criterion stated: `bootstrap.workspace-guard`, -`workspace-relocated`, `workspace-invalid`, `print-env-workspace-conflict`, -`manifest`, `sdk-root-unresolved`, `zephyr-base-manifest-mismatch`, -`zephyr-base-stale`, `zephyr-base-incompatible`, `west-config-reconciled`, -`west-config-reconcile-failed`, `pip-upgrade`, `zephyr-requirements`, -`sdk-extras`, `editable-install`, `failed`; and -`debug-config.comments-dropped`, `legacy-entry-migrated`, -`legacy-entry-untouched`, `internal-failure`, `write-failure`. - -### Frozen `data` field names, and exactly what covers each - -| Field the extension reads | Command family | Gated by | -|---|---|---| -| `data.soms[]`, `.sku`, `.displayName`, `.family`, `.cores[].{id,os}` | `presets` | golden `presets-heterogeneous-som` (a55/yocto + m33/zephyr) | -| `data.sdkRoot`, `.skus`, `.libraries`, `.boardLibraries`, … | `presets` | goldens `presets-no-sdk` + `presets-heterogeneous-som` | -| `data.available.projectTemplates` (+ `moduleTemplates`, `generationTargets`), `data.summary`, `data.details` | `explain` | golden `explain-overview` | -| `data.examples[].{id,sourceDir,title,description}` | `examples` | golden `examples-catalog` | -| `data.targets` / `.written` / `.failed` | `generate` | golden `generate-board-yaml-missing` | -| `data.checks[].{name,status}`, `data.summary.{pass,warn,fail}`, `data.nextSteps`, the literal check name `workspace` | `doctor --build` | `doctor_build_data_keys_the_extension_reads` — a KEY-SET assertion, not a golden, because doctor's values are host facts | -| `data.written` | `build --materialise` | **NOT COVERED.** Reaching it needs a resolvable alp-sdk checkout and a Python spawn; nothing in this suite is allowed either. | -| `data.releases` | `sdk list` | **NOT COVERED.** Hits the GitHub releases API. | - -The last two rows are stated rather than quietly omitted: an uncovered field -that reads as covered is worse than one everybody knows about. - -`tan doctor` WITHOUT `--build` emits a different check vocabulary -(`workspaceRoot`, `lldb`, `longPaths`, …). No consumer matches those by name, -so they are deliberately not frozen. - -### Published as a release asset - -Every tagged release carries **`envelope-contract.json`** beside the binaries: - -```jsonc -{ - "schemaVersion": 1, - "tanVersion": "0.4.0", - "issueCodes": [ /* issue-codes.json, verbatim */ ], - "envelopes": { - "presets-heterogeneous-som": { "args": [...], "exitCode": 0, "envelope": { ... } } - // …one entry per golden case - } -} -``` - -Built by the `Bundle the envelope contract` step in -`.github/workflows/release.yml` — pure re-packaging of committed files that -`cargo test` already gates, so nothing in the asset can disagree with the -repo. It exists so the extension's own contract test diffs against a -published artefact instead of a hand-copied fixture that drifts. Fetch it at -`https://github.com/alplabai/tan-cli/releases/download//envelope-contract.json`. - -## Fixture shape (`envelopes//`) - -One directory per case, mirroring the retired `cli-rs/contract` harness: - -| File | Contents | -|---|---| -| `args.txt` | The `tan` argv, **one token per line** (not shell-split — avoids quoting ambiguity across platforms). | -| `expected.json` | The full golden envelope, normalized (see below). | -| `expected.exit` | The golden process exit code, as a bare integer. | -| *(optional)* `board.yaml` / other fixture inputs | Copied into the isolated working directory the case runs in before `tan` is spawned. **Directories are copied recursively**, which is what lets a case ship a synthetic `sdk/` checkout (`scripts/alp_project.py` + `metadata/…` + `examples/…`) and pass `--sdk-root ./sdk`. That relative argv keeps the "no absolute paths in argv" rule intact — `data.sdkRoot` comes back as the literal `./sdk` on every platform. | - -`contract/fixtures/` (sibling directory) is unrelated — it holds a synthetic -SDK checkout tree consumed by `crates/tan-cli/src/commands/presets.rs`'s own -unit tests, not an envelope golden. - -## What a golden does NOT cover: key ORDER - -The diff is `assert_eq!` on two `serde_json::Value`s, and under this -workspace's `preserve_order` feature `Map`'s equality is **order-insensitive** -(it is `IndexMap`'s). So a golden pins the key **set** and the values, never -the order they are emitted in — swapping `serde_json::Map::shift_remove` for -the order-scrambling `remove` in `debug_launch.rs` leaves all twelve cases -green while failing named `tan-core` unit tests. Key order is a real contract -for the commands that mirror the TS CLI's output; pin it with a serialized- -string assertion in the owning module's own tests, not here. - -## Determinism - -The harness makes every case reproducible on any machine or CI runner, not -just the one that captured it: - -- **Isolated working directory** — each case runs in a fresh, empty temp - directory, never inside the checkout. `tan init`'s create/update file diff - and `tan`'s sibling-`alp-sdk` auto-discovery both read the current - directory's contents, so running inside the repo tree would make a golden - depend on incidental files at the checkout location. -- **Isolated working-directory PARENT** — that fresh directory is itself - nested under its own fresh, uniquely named parent - (`.../tan-contract--/root`), never spawned directly under the - shared system temp root. `discover_workspace_sdk` (tan-core `project.rs`) - probes the working directory's *parent* for a sibling `alp-sdk/`; if that - parent were the shared temp root, a stray `alp-sdk` checkout left there by - something else could flip a golden's `sourceTier` to `discovery`. -- **Isolated `HOME`/`USERPROFILE`** — also a fresh temp directory per case, - so a developer's real `~/.alp/sdk-default` (or lack of one) can never - change what `sdk current` reports. -- **`SOURCE_DATE_EPOCH=0`** — honored by `crate::util::generated_at_iso`; set - unconditionally even though none of the current cases emit a timestamp, so - a future timestamped case is covered without touching the harness. -- **No absolute paths in argv** — every case invokes `tan` without - `--project`/`--sdk-root`/`--destination`, so path fields the CLI reflects - back (`project.root`, `boardYamlPath`, …) come out as `.`/`./board.yaml` - rather than an absolute, machine-specific path. Nothing needed a - `__SDKROOT__`-style substitution token (the convention `tests/parity/` - uses) as a result. -- **Scoped path-separator normalization** — the one thing case selection - can't avoid by construction: `PathBuf::to_string_lossy()` renders - `./board.yaml` as `.\board.yaml` on Windows. The harness normalizes - `\` → `/` on the freshly captured side before diffing, but only on the - known path-shaped fields (`root`, `boardYaml`, `boardYamlPath`, - `destination`, `relativePath`, `sdkPath`, `sdkPinned`, `written`, - `unchanged`, `launchJsonPath` — see `PATH_KEYS` in `contract.rs`), not every - string leaf. A blanket rewrite would also launder a real drift inside - `issues[].message` or any other value that happens to contain a backslash — - exactly the kind of change this gate exists to catch. Committed goldens are - authored with forward slashes in those fields, matching the normalized form. -- **`__WORKDIR__` for a reflected absolute path** — the one case the - "no absolute paths in argv" rule above cannot cover: `debug-config` reports - the working directory it resolved (`project.root`) and the `launch.json` - path it would write, absolute, whatever the argv. Those two fields are - substituted down to the `__WORKDIR__` token on the captured side. The - substitution anchors on the case's unique scratch-dir marker - (`tan-contract--/root`) rather than on the harness's own - `work_dir` string, because on macOS `$TMPDIR` is a symlink that - `std::env::current_dir()` resolves through (`/var/…` → `/private/var/…`) and - a whole-prefix comparison would silently stop matching there. Like the - separator rewrite, it applies to `PATH_KEYS` fields only. - -## Cases pinned today - -| Case | Command | Exit | Why | -|---|---|---|---| -| `init-preview-minimal-app` | `init --template minimal-app --preview --format json` | 0 | Deterministic scaffold plan — the envelope shape `init` templates hand the extension (`{schemaVersion,templateId,destination,preview,fileChanges,written,unchanged,sdkPinned}`). | -| `init-invalid-template` | `init --template bogus-template --format json` | 2 | Validation-failure envelope shape for `init`. | -| `validate-offline-clean` | `validate --offline --format json` (fixture `board.yaml`) | 0 | The offline structural validator's clean-outcome envelope — no Python/SDK spawn, so it's genuinely deterministic and network-free. | -| `validate-offline-schema-violation` | `validate --offline --format json` (malformed fixture `board.yaml`) | 2 | Same command, non-clean outcome — pins the `issues[]` shape too. | -| `validate-offline-empty-document` | `validate --offline --format json` (empty fixture `board.yaml`) | 2 | An empty/comment-only document used to report exit 0 "clean" — the silent-failure shape Python's `validate_board_text` refuses as a `BoardShapeError`. Pins that the Rust port refuses it too, message and exit code alike. | -| `sdk-current-no-sdk` | `sdk current --format json` | 0 | Reports `sourceTier: "none"` in a workspace with no SDK configured — offline, host-independent given the isolated `HOME`. | -| `sdk-unknown-subcommand` | `sdk bogus --format json` | 1 | Runtime-failure envelope shape; the only offline path that exercises exit code 1 in this set. | -| `generate-board-yaml-missing` | `generate --format json` (no `board.yaml` present) | 2 | `generate`'s `data` schema (`{schemaVersion,targets,written,failed}`) is distinct from `init`'s and was otherwise completely unguarded — this is `generate`'s first guard clause (`commands/generate.rs`'s `run()`), needing no board/SDK/Python/network to reach. | -| `debug-config-preview-zephyr-mcu` | `debug-config --target-kind zephyr-mcu --server jlink --preview` | 0 | | -| `debug-config-preview-baremetal-mcu` | `debug-config --target-kind baremetal-mcu --server openocd --preview` | 0 | | -| `debug-config-preview-yocto-userspace` | `debug-config --target-kind yocto-userspace --server gdbserver --preview` | 0 | | -| `debug-config-preview-native-host` | `debug-config --target-kind native-host --server none --preview` | 0 | One profile per `--target-kind`. Unlike the other cases these pin a `data` value that is itself a consumer ARTEFACT, not a report: alp-sdk-vscode#342 writes `data.configuration` into `launch.json` verbatim, so the golden pins the emitted key SET — an added key (the `preLaunchTask` these fixtures were added after, which named a task nothing defines and made VS Code abort pre-launch) or a changed `program`/`executable` fails here instead of shipping. `--preview` reads no `board.yaml`, spawns no Python and probes no PATH; the only host-dependent output is the absolute working directory, tokenized as `__WORKDIR__` above. | -| `presets-no-sdk` | `presets --format json` (no SDK resolvable) | 0 | Pins the `presets.sdk-root-unresolved` warning ON THE WIRE — the one frozen issue code reachable hermetically — plus the full `PresetsData` key set with `soms: []`. | -| `presets-heterogeneous-som` | `presets --sdk-root ./sdk --format json` (fixture SDK) | 0 | Issue #106's worked example made executable. The fixture SoM has an `a55` (`machine:` → yocto) and an `m33` (`board:` → zephyr), so `data.soms[].cores[].{id,os}` carries two different values — rename `soms` or `cores` and this fails instead of quietly scaffolding a multi-core part single-core with no IPC. Also pins `boardLibraries` discovery. | -| `explain-overview` | `explain --format json` | 0 | `data.available.projectTemplates`, the New Project wizard's starter list. Fully hermetic — the catalogues are static, no SDK involved. | -| `examples-catalog` | `examples --sdk-root ./sdk --format json` (fixture SDK) | 0 | `data.examples[].sourceDir`, which is what `tan init --from-example ` is handed back; a rename breaks scaffolding from an SDK example. Also pins README-derived `title`/`description`. | -| `version_first_line_matches_contract` (in `contract.rs`, no fixture dir) | `--version` | 0 | Not a golden diff — `tan MAJOR.MINOR.PATCH` would need editing on every release if pinned literally, so the test asserts the *format* instead. | -| `frozen_issue_codes` (in `contract.rs`, no fixture dir) | — | — | Source-literal assertion over `issue-codes.json`. Not a golden because the two `bootstrap.*` codes are not reachable hermetically: `yocto-host` fires only on a non-Linux host (a golden would be inert on the ubuntu CI leg) and `prerequisites-missing` only when a tool is absent from PATH. It proves the SPELLING survives at the emission site, **not** that the code still reaches the wire — that residue is stated in the test's own doc comment too. | -| `doctor_build_data_keys_the_extension_reads` (in `contract.rs`, no fixture dir) | `doctor --build --format json` | — | KEY-SET assertion, not a value diff: doctor's values are host facts (what is on PATH, whether a Zephyr workspace exists), its key names are not. Covers `data.summary.{pass,warn,fail}`, `data.nextSteps`, `data.checks[].{name,status}` and the literal check name `workspace`. | - -Deliberately not covered: `sdk list` (hits the GitHub releases API — network), -`build --materialise`'s `data.written` (needs a resolvable SDK + a Python -spawn), `kconfig` (the SDK's -`--emit kconfig` needs a bootstrapped `ZEPHYR_BASE` — alp-sdk's one -workspace-dependent emit, see alp-sdk `docs/cli.md`; `tan kconfig`'s pure -JSON→`KconfigData`→envelope shaping is unit-tested hermetically in -`crates/tan-cli/src/commands/kconfig.rs` and `crates/tan-core/src/kconfig.rs` -instead). Not exhaustive by design — this pins the envelope *shape* + -exit-code contract for the commands the extension actually parses, not full -command coverage. What is uncovered is listed rather than omitted: silence -reading as coverage is how an inert gate survives. - -## Regenerating a golden after a *deliberate* envelope change - -There is no `--bless` flag (the retired shell harness had one; the Rust -suite doesn't need the extra code at this fixture count). To update a golden on -purpose: - -1. Build `tan` and run the case's `args.txt` by hand from an empty directory, - with `SOURCE_DATE_EPOCH=0` and `HOME`/`USERPROFILE` pointed at another - empty directory, `--format json`. -2. Copy the printed envelope into `expected.json`, converting any `\` path - separator to `/` (Windows only — Unix output is already normalized). -3. Update `expected.exit` if the exit code changed. -4. Re-run `cargo test -p tan --test contract` and confirm it passes. -5. Explain the *intentional* shape change in the commit message — a golden - update with no explanation of why the wire format changed is exactly the - drift this gate exists to catch. + +# `contract/` — the JSON envelope drift gate + +The vscode extension drives `tan --format json` and hard-depends on +five things that nothing else in this repo pins: + +- the top-level envelope shape, `{ command, ok, exitCode, project, data, + issues }` (`crates/tan-cli/src/envelope.rs`); +- the exit-code contract — 0 success, 1 runtime, 2 validation, 3 write, 4 + doctor, 5 internal (`crates/tan-cli/src/exit.rs`); +- `tan --version`'s first stdout line, `tan MAJOR.MINOR.PATCH`; +- the **frozen issue codes** it matches with `===` (`issue-codes.json`, below); +- the **`data` field names** it reads with `?? []` fallbacks (below). + +`crates/tan-cli/tests/contract.rs` (run by `cargo test`, part of the normal +CI `test` job — no separate CI wiring needed) spawns the real, compiled `tan` +binary against the golden fixtures in `envelopes/` below and diffs the +result. A breaking wire-format change fails `cargo test` here instead of +being discovered later, silently, in the extension. + +This is a **Rust integration test, not a shell script** (unlike the retired +`cli-rs/contract/run.sh`): `cargo test` already runs it cross-platform (this +repo's CI test job matrixes ubuntu/windows/macos-latest — a bash harness +would need a second execution path on Windows CI runners for no benefit), +needs no new CI job, and gets `cargo`'s own binary discovery +(`CARGO_BIN_EXE_tan`) for free instead of a hand-rolled `target/debug/tan(.exe)` +path. + +## The frozen wire vocabulary (issue #106) + +`tan`'s envelope is a **versioned public contract**, not an implementation +detail. Two parts of it are matched by string on the consumer side, and both +matches **fail open**: an unrecognised issue code returns "no verdict" and a +missing `data` key falls back to `?? []`. The extension does not error, does +not log and does not warn — it silently skips the check or renders stale +data, with CI green on both sides. A rename here is therefore +indistinguishable from "no problem" until a customer hits it. + +**Do not fix a rename by loosening the consumer.** A prefix match on +`bootstrap.` would swallow codes the extension has no verdict for. The +contract belongs to whoever owns the envelope: this repo. + +### Exit codes (`crates/tan-cli/src/exit.rs`) + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | Runtime failure (I/O, subprocess) | +| 2 | Validation failure (schema/semantic) | +| 3 | Write failure | +| 4 | `doctor` reported an unhealthy environment | +| 5 | Internal error (bug / unreachable state) | + +### Frozen issue codes (`issue-codes.json`) + +`issue-codes.json` is the single source; `contract.rs`'s `frozen_issue_codes` +gates it, and the release workflow publishes it. Renaming or removing a +`status: "frozen"` code is a **breaking wire change** — bump the CLI +MAJOR/MINOR, record it in `CHANGELOG.md`, and open the matching +alp-sdk-vscode issue. A `status: "reserved"` code has no consumer yet +(`consumer: "none"`): the gate still checks the spelling exists at the +emission site, but renaming or dropping it costs nothing on the wire — +promote it to `frozen` the moment a consumer actually binds to it. + +**Selection criterion for the table below: `frozen`/`retired` codes only** — +the ones where a rename or removal is the actual breaking wire change this +whole file exists to guard against. `reserved` codes are cheap to rename by +definition (nothing binds them with `===` yet), there are more of them than +usefully fit a table, and `issue-codes.json` is already their single source +with a full `consumerEffect` per entry — this table does not duplicate them. + +| Code | Status | Consumer effect if renamed | +|---|---|---| +| `bootstrap.windows-unsupported` (severity `error`) | retired | Emitted by tan ≤ v0.3.0 only. The consumer branch is permanent back-compat for anyone pinned to an old binary via `alpSdk.cliPath`, so the spelling is RESERVED and must never be re-used for a different verdict. | +| `bootstrap.yocto-host` (severity `error`) | frozen | A Yocto-only project is sent into a bootstrap that cannot work on this host. The mixed-board case reuses the suffix at severity `warning` and must stay a warning. | +| `bootstrap.prerequisites-missing` (severity `error`) | frozen | tan's own refusal is not recognised, so the extension spawns the real bootstrap terminal anyway and the customer watches the identical failure scroll past with the install guidance lost. | +| `presets.sdk-root-unresolved` (severity `warning`) | frozen | The New Project wizard silently falls back to its static catalogue, which carries no `cores`, so a **heterogeneous SoM scaffolds single-core with no IPC**. The reference part E1M-AEN801 is multi-core, so that is the default path. | +| `bootstrap.python-not-runnable` (severity `error`) | frozen | `python`/`python3` resolves on PATH but will not run (a Microsoft Store alias, or similar). Renamed, `alp-sdk-vscode`'s `prerequisitesMissingIssue` (`PREREQ_CODES`, `src/alpCli/service.ts`) no longer recognises tan's own refusal, so the extension spawns the real bootstrap terminal anyway and the customer watches the identical failure scroll past with the install guidance lost — same failure shape as `bootstrap.prerequisites-missing`. Carries no `missingPrerequisites[]` entry: a `{tool, command}` pair cannot represent "the Python you have will not run", so the fix travels only in `issues[].message`. | +| `bootstrap.python-too-old` (severity `error`) | frozen | The resolved Python is below the SDK tooling's floor (currently >= 3.10). Same consumer and the same failure shape as `bootstrap.python-not-runnable`; also tool-less. | + +`bootstrap.prerequisites-missing`, `bootstrap.python-not-runnable` and +`bootstrap.python-too-old` are the three codes `alp-sdk-vscode`'s +`prerequisitesMissingIssue` matches (`PREREQ_CODES`, a `Set` matched with +`.has()` — equivalent to `===` for this purpose) to stop it spawning a real +bootstrap that has already been refused. The latter two carry no missing +TOOL at all, so `missingPrerequisites[]` is always empty for them; the fix +travels only in `issues[].message` (see +`crates/tan-core/src/bootstrap/prerequisites.rs`). + +Every other registered code is `reserved` — no consumer binds any of them +yet, so renaming or dropping one costs nothing on the wire, and none is +tabled above per the criterion stated: `bootstrap.workspace-guard`, +`workspace-relocated`, `workspace-invalid`, `print-env-workspace-conflict`, +`manifest`, `sdk-root-unresolved`, `zephyr-base-manifest-mismatch`, +`zephyr-base-stale`, `zephyr-base-incompatible`, `west-config-reconciled`, +`west-config-reconcile-failed`, `pip-upgrade`, `zephyr-requirements`, +`sdk-extras`, `editable-install`, `failed`; and +`debug-config.comments-dropped`, `legacy-entry-migrated`, +`legacy-entry-untouched`, `internal-failure`, `write-failure`. + +### Frozen `data` field names, and exactly what covers each + +| Field the extension reads | Command family | Gated by | +|---|---|---| +| `data.soms[]`, `.sku`, `.displayName`, `.family`, `.cores[].{id,os}` | `presets` | golden `presets-heterogeneous-som` (a55/yocto + m33/zephyr) | +| `data.sdkRoot`, `.skus`, `.libraries`, `.boardLibraries`, … | `presets` | goldens `presets-no-sdk` + `presets-heterogeneous-som` | +| `data.available.projectTemplates` (+ `moduleTemplates`, `generationTargets`), `data.summary`, `data.details` | `explain` | golden `explain-overview` | +| `data.examples[].{id,sourceDir,title,description}` | `examples` | golden `examples-catalog` | +| `data.targets` / `.written` / `.failed` | `generate` | golden `generate-board-yaml-missing` | +| `data.checks[].{name,status}`, `data.summary.{pass,warn,fail}`, `data.nextSteps`, the literal check name `workspace` | `doctor --build` | `doctor_build_data_keys_the_extension_reads` — a KEY-SET assertion, not a golden, because doctor's values are host facts | +| `data.written` | `build --materialise` | **NOT COVERED.** Reaching it needs a resolvable alp-sdk checkout and a Python spawn; nothing in this suite is allowed either. | +| `data.releases` | `sdk list` | **NOT COVERED.** Hits the GitHub releases API. | + +The last two rows are stated rather than quietly omitted: an uncovered field +that reads as covered is worse than one everybody knows about. + +`tan doctor` WITHOUT `--build` emits a different check vocabulary +(`workspaceRoot`, `lldb`, `longPaths`, …). No consumer matches those by name, +so they are deliberately not frozen. + +### Published as a release asset + +Every tagged release carries **`envelope-contract.json`** beside the binaries: + +```jsonc +{ + "schemaVersion": 1, + "tanVersion": "0.4.0", + "issueCodes": [ /* issue-codes.json, verbatim */ ], + "envelopes": { + "presets-heterogeneous-som": { "args": [...], "exitCode": 0, "envelope": { ... } } + // …one entry per golden case + } +} +``` + +Built by the `Bundle the envelope contract` step in +`.github/workflows/release.yml` — pure re-packaging of committed files that +`cargo test` already gates, so nothing in the asset can disagree with the +repo. It exists so the extension's own contract test diffs against a +published artefact instead of a hand-copied fixture that drifts. Fetch it at +`https://github.com/alplabai/tan-cli/releases/download//envelope-contract.json`. + +## Fixture shape (`envelopes//`) + +One directory per case, mirroring the retired `cli-rs/contract` harness: + +| File | Contents | +|---|---| +| `args.txt` | The `tan` argv, **one token per line** (not shell-split — avoids quoting ambiguity across platforms). | +| `expected.json` | The full golden envelope, normalized (see below). | +| `expected.exit` | The golden process exit code, as a bare integer. | +| *(optional)* `board.yaml` / other fixture inputs | Copied into the isolated working directory the case runs in before `tan` is spawned. **Directories are copied recursively**, which is what lets a case ship a synthetic `sdk/` checkout (`scripts/alp_project.py` + `metadata/…` + `examples/…`) and pass `--sdk-root ./sdk`. That relative argv keeps the "no absolute paths in argv" rule intact — `data.sdkRoot` comes back as the literal `./sdk` on every platform. | + +`contract/fixtures/` (sibling directory) is unrelated — it holds a synthetic +SDK checkout tree consumed by `crates/tan-cli/src/commands/presets.rs`'s own +unit tests, not an envelope golden. + +## What a golden does NOT cover: key ORDER + +The diff is `assert_eq!` on two `serde_json::Value`s, and under this +workspace's `preserve_order` feature `Map`'s equality is **order-insensitive** +(it is `IndexMap`'s). So a golden pins the key **set** and the values, never +the order they are emitted in — swapping `serde_json::Map::shift_remove` for +the order-scrambling `remove` in `debug_launch.rs` leaves all twelve cases +green while failing named `tan-core` unit tests. Key order is a real contract +for the commands that mirror the TS CLI's output; pin it with a serialized- +string assertion in the owning module's own tests, not here. + +## Determinism + +The harness makes every case reproducible on any machine or CI runner, not +just the one that captured it: + +- **Isolated working directory** — each case runs in a fresh, empty temp + directory, never inside the checkout. `tan init`'s create/update file diff + and `tan`'s sibling-`alp-sdk` auto-discovery both read the current + directory's contents, so running inside the repo tree would make a golden + depend on incidental files at the checkout location. +- **Isolated working-directory PARENT** — that fresh directory is itself + nested under its own fresh, uniquely named parent + (`.../tan-contract--/root`), never spawned directly under the + shared system temp root. `discover_workspace_sdk` (tan-core `project.rs`) + probes the working directory's *parent* for a sibling `alp-sdk/`; if that + parent were the shared temp root, a stray `alp-sdk` checkout left there by + something else could flip a golden's `sourceTier` to `discovery`. +- **Isolated `HOME`/`USERPROFILE`** — also a fresh temp directory per case, + so a developer's real `~/.alp/sdk-default` (or lack of one) can never + change what `sdk current` reports. +- **`SOURCE_DATE_EPOCH=0`** — honored by `crate::util::generated_at_iso`; set + unconditionally even though none of the current cases emit a timestamp, so + a future timestamped case is covered without touching the harness. +- **No absolute paths in argv** — every case invokes `tan` without + `--project`/`--sdk-root`/`--destination`, so path fields the CLI reflects + back (`project.root`, `boardYamlPath`, …) come out as `.`/`./board.yaml` + rather than an absolute, machine-specific path. Nothing needed a + `__SDKROOT__`-style substitution token (the convention `tests/parity/` + uses) as a result. +- **Scoped path-separator normalization** — the one thing case selection + can't avoid by construction: `PathBuf::to_string_lossy()` renders + `./board.yaml` as `.\board.yaml` on Windows. The harness normalizes + `\` → `/` on the freshly captured side before diffing, but only on the + known path-shaped fields (`root`, `boardYaml`, `boardYamlPath`, + `destination`, `relativePath`, `sdkPath`, `sdkPinned`, `written`, + `unchanged`, `launchJsonPath` — see `PATH_KEYS` in `contract.rs`), not every + string leaf. A blanket rewrite would also launder a real drift inside + `issues[].message` or any other value that happens to contain a backslash — + exactly the kind of change this gate exists to catch. Committed goldens are + authored with forward slashes in those fields, matching the normalized form. +- **`__WORKDIR__` for a reflected absolute path** — the one case the + "no absolute paths in argv" rule above cannot cover: `debug-config` reports + the working directory it resolved (`project.root`) and the `launch.json` + path it would write, absolute, whatever the argv. Those two fields are + substituted down to the `__WORKDIR__` token on the captured side. The + substitution anchors on the case's unique scratch-dir marker + (`tan-contract--/root`) rather than on the harness's own + `work_dir` string, because on macOS `$TMPDIR` is a symlink that + `std::env::current_dir()` resolves through (`/var/…` → `/private/var/…`) and + a whole-prefix comparison would silently stop matching there. Like the + separator rewrite, it applies to `PATH_KEYS` fields only. + +## Cases pinned today + +| Case | Command | Exit | Why | +|---|---|---|---| +| `init-preview-minimal-app` | `init --template minimal-app --preview --format json` | 0 | Deterministic scaffold plan — the envelope shape `init` templates hand the extension (`{schemaVersion,templateId,destination,preview,fileChanges,written,unchanged,sdkPinned}`). | +| `init-invalid-template` | `init --template bogus-template --format json` | 2 | Validation-failure envelope shape for `init`. | +| `validate-offline-clean` | `validate --offline --format json` (fixture `board.yaml`) | 0 | The offline structural validator's clean-outcome envelope — no Python/SDK spawn, so it's genuinely deterministic and network-free. | +| `validate-offline-schema-violation` | `validate --offline --format json` (malformed fixture `board.yaml`) | 2 | Same command, non-clean outcome — pins the `issues[]` shape too. | +| `validate-offline-empty-document` | `validate --offline --format json` (empty fixture `board.yaml`) | 2 | An empty/comment-only document used to report exit 0 "clean" — the silent-failure shape Python's `validate_board_text` refuses as a `BoardShapeError`. Pins that the Rust port refuses it too, message and exit code alike. | +| `sdk-current-no-sdk` | `sdk current --format json` | 0 | Reports `sourceTier: "none"` in a workspace with no SDK configured — offline, host-independent given the isolated `HOME`. | +| `sdk-unknown-subcommand` | `sdk bogus --format json` | 1 | Runtime-failure envelope shape; the only offline path that exercises exit code 1 in this set. | +| `generate-board-yaml-missing` | `generate --format json` (no `board.yaml` present) | 2 | `generate`'s `data` schema (`{schemaVersion,targets,written,failed}`) is distinct from `init`'s and was otherwise completely unguarded — this is `generate`'s first guard clause (`commands/generate.rs`'s `run()`), needing no board/SDK/Python/network to reach. | +| `debug-config-preview-zephyr-mcu` | `debug-config --target-kind zephyr-mcu --server jlink --preview` | 0 | | +| `debug-config-preview-baremetal-mcu` | `debug-config --target-kind baremetal-mcu --server openocd --preview` | 0 | | +| `debug-config-preview-yocto-userspace` | `debug-config --target-kind yocto-userspace --server gdbserver --preview` | 0 | | +| `debug-config-preview-native-host` | `debug-config --target-kind native-host --server none --preview` | 0 | One profile per `--target-kind`. Unlike the other cases these pin a `data` value that is itself a consumer ARTEFACT, not a report: alp-sdk-vscode#342 writes `data.configuration` into `launch.json` verbatim, so the golden pins the emitted key SET — an added key (the `preLaunchTask` these fixtures were added after, which named a task nothing defines and made VS Code abort pre-launch) or a changed `program`/`executable` fails here instead of shipping. `--preview` reads no `board.yaml`, spawns no Python and probes no PATH; the only host-dependent output is the absolute working directory, tokenized as `__WORKDIR__` above. | +| `presets-no-sdk` | `presets --format json` (no SDK resolvable) | 0 | Pins the `presets.sdk-root-unresolved` warning ON THE WIRE — the one frozen issue code reachable hermetically — plus the full `PresetsData` key set with `soms: []`. | +| `presets-heterogeneous-som` | `presets --sdk-root ./sdk --format json` (fixture SDK) | 0 | Issue #106's worked example made executable. The fixture SoM has an `a55` (`machine:` → yocto) and an `m33` (`board:` → zephyr), so `data.soms[].cores[].{id,os}` carries two different values — rename `soms` or `cores` and this fails instead of quietly scaffolding a multi-core part single-core with no IPC. Also pins `boardLibraries` discovery. | +| `explain-overview` | `explain --format json` | 0 | `data.available.projectTemplates`, the New Project wizard's starter list. Fully hermetic — the catalogues are static, no SDK involved. | +| `examples-catalog` | `examples --sdk-root ./sdk --format json` (fixture SDK) | 0 | `data.examples[].sourceDir`, which is what `tan init --from-example ` is handed back; a rename breaks scaffolding from an SDK example. Also pins README-derived `title`/`description`. | +| `version_first_line_matches_contract` (in `contract.rs`, no fixture dir) | `--version` | 0 | Not a golden diff — `tan MAJOR.MINOR.PATCH` would need editing on every release if pinned literally, so the test asserts the *format* instead. | +| `frozen_issue_codes` (in `contract.rs`, no fixture dir) | — | — | Source-literal assertion over `issue-codes.json`. Not a golden because the two `bootstrap.*` codes are not reachable hermetically: `yocto-host` fires only on a non-Linux host (a golden would be inert on the ubuntu CI leg) and `prerequisites-missing` only when a tool is absent from PATH. It proves the SPELLING survives at the emission site, **not** that the code still reaches the wire — that residue is stated in the test's own doc comment too. | +| `doctor_build_data_keys_the_extension_reads` (in `contract.rs`, no fixture dir) | `doctor --build --format json` | — | KEY-SET assertion, not a value diff: doctor's values are host facts (what is on PATH, whether a Zephyr workspace exists), its key names are not. Covers `data.summary.{pass,warn,fail}`, `data.nextSteps`, `data.checks[].{name,status}` and the literal check name `workspace`. | + +Deliberately not covered: `sdk list` (hits the GitHub releases API — network), +`build --materialise`'s `data.written` (needs a resolvable SDK + a Python +spawn), `kconfig` (the SDK's +`--emit kconfig` needs a bootstrapped `ZEPHYR_BASE` — alp-sdk's one +workspace-dependent emit, see alp-sdk `docs/cli.md`; `tan kconfig`'s pure +JSON→`KconfigData`→envelope shaping is unit-tested hermetically in +`crates/tan-cli/src/commands/kconfig.rs` and `crates/tan-core/src/kconfig.rs` +instead). Not exhaustive by design — this pins the envelope *shape* + +exit-code contract for the commands the extension actually parses, not full +command coverage. What is uncovered is listed rather than omitted: silence +reading as coverage is how an inert gate survives. + +## Regenerating a golden after a *deliberate* envelope change + +There is no `--bless` flag (the retired shell harness had one; the Rust +suite doesn't need the extra code at this fixture count). To update a golden on +purpose: + +1. Build `tan` and run the case's `args.txt` by hand from an empty directory, + with `SOURCE_DATE_EPOCH=0` and `HOME`/`USERPROFILE` pointed at another + empty directory, `--format json`. +2. Copy the printed envelope into `expected.json`, converting any `\` path + separator to `/` (Windows only — Unix output is already normalized). +3. Update `expected.exit` if the exit code changed. +4. Re-run `cargo test -p tan --test contract` and confirm it passes. +5. Explain the *intentional* shape change in the commit message — a golden + update with no explanation of why the wire format changed is exactly the + drift this gate exists to catch. diff --git a/contract/issue-codes.json b/contract/issue-codes.json index 7876bb49..ffff9469 100644 --- a/contract/issue-codes.json +++ b/contract/issue-codes.json @@ -1,715 +1,2434 @@ -{ - "schemaVersion": 1, - "_comment": [ - "FROZEN issue codes: the exact `issues[].code` strings alp-sdk-vscode", - "matches with `===` to gate real behaviour. Every one of those matches", - "FAILS OPEN -- an unrecognised code is indistinguishable from 'no", - "problem', so a rename here is silent on both sides with CI green. See", - "alplabai/tan-cli#106.", - "", - "This file is the single source: `crates/tan-cli/tests/contract.rs`", - "(`frozen_issue_codes`) gates it against the emitting sources, and the", - "release workflow folds it into the published `envelope-contract.json`", - "asset so the extension's own contract test can diff against an artefact", - "instead of a hand-copied fixture.", - "", - "Adding a `frozen` code is cheap. REMOVING or RENAMING one is a breaking", - "wire change: bump the CLI MAJOR/MINOR, say so in CHANGELOG.md, and open", - "the matching issue on alp-sdk-vscode. Do not 'fix' a rename by", - "loosening the consumer to a prefix match -- `bootstrap.` would swallow", - "codes it has no verdict for.", - "", - "A `reserved` code is the pre-consumer state: the spelling exists at the", - "emission site (the gate still checks that) but `consumer` is \"none\" --", - "nobody matches it with `===` yet, so renaming or dropping it costs", - "nothing on the wire. Promote a `reserved` code to `frozen` the moment a", - "consumer binds to it (fill in `consumer`/`consumerEffect` for real); do", - "not invent a third status for that transition.", - "", - "EVERY literal emit site must appear here at some status (tan-cli#219).", - "`frozen_issue_codes` only ever walked registry -> source, so a code that", - "was never registered was ungated on BOTH sides at once: this repo's", - "checks iterate the registry and never saw it, and alp-sdk-vscode's gate", - "keys off the published artefact, which is built from this same registry.", - "A rename of an unregistered code was invisible to both repos", - "simultaneously. `every_emitted_issue_code_is_registered` walks the other", - "way and fails when an emitted code has no entry. 41 codes were in that", - "state when it landed; they are `reserved`, NOT `frozen` -- freezing what", - "no consumer reads would over-commit and make every future internal", - "rename a contract break for nobody's benefit.", - "", - "The published `envelope-contract.json` carries this array WHOLE, all", - "three statuses, not a frozen-only subset -- so the artefact's code list", - "is everything tan emits, and a consumer reads `status` to decide what a", - "code promises. Keep it that way: a silently-partial artefact that", - "presents itself as the contract is worse than either honest option." - ], - "issueCodes": [ - { - "code": "bootstrap.windows-unsupported", - "status": "retired", - "severity": "error", - "consumer": "alp-sdk-vscode src/alpCli/service.ts :: bootstrapHostVerdict", - "consumerEffect": "Refuses the Windows bootstrap and offers 'Reopen in WSL'.", - "note": "Emitted by tan v0.3.0 and EARLIER only (the retired commands/bootstrap.rs, which shelled the SDK's POSIX bootstrap.sh). Native Windows bootstrap shipped in v0.3.1, so current tan never emits it -- but the consumer branch is permanent back-compat for anyone pinned to an old binary via alpSdk.cliPath. RESERVED: this spelling must never be reused for a different verdict, which is what the gate asserts." - }, - { - "code": "bootstrap.yocto-host", - "status": "frozen", - "severity": "error", - "consumer": "alp-sdk-vscode src/alpCli/service.ts :: bootstrapHostVerdict", - "consumerEffect": "Refuses to bootstrap a Yocto-only project on a non-Linux host. Renamed, the project is sent into a bootstrap that cannot work here.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"yocto-host\"", - "note": "The consumer ALSO requires severity 'error'. The mixed-board WARNING reuses this same suffix at severity 'warning' and must stay a warning -- promoting it would refuse a board that can bootstrap its Zephyr cores." - }, - { - "code": "bootstrap.prerequisites-missing", - "status": "frozen", - "severity": "error", - "consumer": "alp-sdk-vscode src/alpCli/service.ts :: prerequisitesMissingIssue", - "consumerEffect": "Stops the extension spawning the real bootstrap terminal after tan already refused. Renamed, the customer watches the identical failure scroll past with the install guidance lost.", - "emittedBy": "crates/tan-core/src/bootstrap/prerequisites.rs", - "literal": "code: \"prerequisites-missing\"", - "note": "The `bootstrap.` prefix is applied by `failure()` in crates/tan-cli/src/commands/bootstrap/mod.rs. NOT the only refusal from that gate: `bootstrap.python-not-runnable` and `bootstrap.python-too-old` are separate codes (no missing TOOL to report) and a consumer wanting those two must match them by name." - }, - { - "code": "presets.sdk-root-unresolved", - "status": "frozen", - "severity": "warning", - "consumer": "alp-sdk-vscode src/ideHub/newProjectFlowPanel.ts", - "consumerEffect": "Warns that the Hardware list carries no core topology. Renamed, the New Project wizard silently falls back to its static E1M_MODULES catalogue and a HETEROGENEOUS SoM scaffolds single-core with no IPC -- the reference part E1M-AEN801 is multi-core, so that is the default path.", - "emittedBy": "crates/tan-cli/src/commands/presets.rs", - "literal": "\"presets.sdk-root-unresolved\"", - "note": "Also pinned end-to-end by the `presets-no-sdk` golden envelope, which is the stronger gate: it asserts the code actually reaches the wire, not just that the string survives in the source." - }, - { - "code": "debug-config.legacy-entry-migrated", - "status": "reserved", - "severity": "info", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to surface a one-time notice that a stale pre-#155 `\"ALP: ...\"` launch-configuration entry was folded into the maintained `\"Alp: ...\"` one: any hand-resolved value on an unresolved-placeholder field (device, miDebuggerServerAddress, configFiles, ...) the customer had filled in on the orphan carried across, while every other field tan owns was refreshed to this run's values (tan-cli#133, reopened).", - "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", - "literal": "\"debug-config.legacy-entry-migrated\"", - "note": "Fires only on the one-time migration path in `tan_core::debug_launch::create_launch_json_write_plan`: an exact-name miss against the current `\"Alp: ...\"` name that then hits the ONE legacy spelling of that same name. It never fires when a current-named entry already exists (whether or not a legacy one also still sits in the file) -- that branch deliberately leaves any leftover legacy entry untouched rather than guessing which of two possibly-hand-edited entries is authoritative." - }, - { - "code": "debug-config.comments-dropped", - "status": "reserved", - "severity": "info", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to surface a one-time notice that a `tan debug-config` write discarded a comment (or trailing comma) sitting inside the byte span it rewrote -- the one launch-configuration entry a splice replaced, or, on the whole-document fallback, the customer's entire original file (tan-cli#182 review finding #2).", - "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", - "literal": "\"debug-config.comments-dropped\"", - "note": "Set from `LaunchJsonWritePlan::comments_dropped` (`tan_core::debug_launch::write_content`): for a splice, whether `strip_jsonc` changes the replaced entry's own original byte span; for the whole-document fallback, whether it changes the original file at all. Never fires on the no-op short-circuit path (an unchanged merge returns `original` verbatim) or on an append (nothing existing is ever rewritten)." - }, - { - "code": "debug-config.sdk-identity-overwrite", - "status": "reserved", - "severity": "info", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to surface a one-time notice that a `tan debug-config` write just replaced a concrete existing `device`/`targetId`/`configFiles` value with one resolved from the SDK's published per-variant debug-probe identity (alp-sdk#987) rather than from a real build (alp-sdk#1026 review finding #1).", - "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", - "literal": "\"debug-config.sdk-identity-overwrite\"", - "note": "Set from `tan_core::debug_launch::sdk_identity_overwrites`, called only for the field(s) `fill_debug_probe_identity_from_sdk` itself populated this run (never for a field a real build's `runners.yaml` already resolved -- that overwrite is pre-existing, intended behaviour per `merge_configuration`'s own doc comment, not something this code is scoped to disclose). Fires once per overwritten field, only on the write path (never `--preview`, which never reads or merges into the existing file at all)." - }, - { - "code": "debug-config.sdk-identity-key-absent", - "status": "reserved", - "severity": "info", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to distinguish 'this SoM's SDK-published debug-probe identity exists but does not include a value for this server's field yet' (e.g. every Alif variant today, for `openocd_config`) from the generic 'still needs resolution' case, which fires for the same reason a pre-build project has no `device` at all.", - "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", - "literal": "\"debug-config.sdk-identity-key-absent\"", - "note": "alp-sdk#1026 review finding #4: the generic 'Placeholder fields...' preview note is real but unspecific, and names `device` even for e.g. an OpenOCD draft that carries no `device` key at all. Fires when `fill_debug_probe_identity_from_sdk` found a `variants[].debug` block for the resolved SoC variant but the field `server_identity_field` maps to this server is still an unresolved placeholder in the draft. Emitted on BOTH `--preview` and a write -- this is advisory about resolution state, not about what a write changed on disk, unlike its `sdk-identity-overwrite` sibling above." - }, - { - "code": "bootstrap.workspace-guard", - "status": "reserved", - "severity": "error", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to distinguish the tan-cli#185 workspace-parent-guard refusal (the checkout's parent holds unrelated content and neither --workspace nor an interactive accept resolved it) from every other bootstrap refusal, so a future UI could offer its own relocation picker instead of just showing the message.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/relocate.rs", - "literal": "code: \"workspace-guard\"", - "note": "The `bootstrap.` prefix is applied by `failure()` in crates/tan-cli/src/commands/bootstrap/mod.rs, same as `prerequisites-missing` above. Fires on BOTH a --non-interactive/--ci/--format json refusal and an interactive decline/cancel -- the two share this one code, distinguished only by `exitCode` (2 vs 1) and by `issues[].message`, which is what a consumer without a code-level split reads instead." - }, - { - "code": "bootstrap.workspace-relocated", - "status": "reserved", - "severity": "warning", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that `tan bootstrap` physically moved the customer's alp-sdk checkout (an accepted tan-cli#185 relocation prompt, or an explicit --workspace naming somewhere new) -- `data.sdkRoot`/`data.workspaceDir` already carry the new location on the wire; this is the narrative flag that it MOVED rather than simply having always been there.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"workspace-relocated\"", - "note": "Recorded via `Log::warn` (steps.rs), which applies the same `bootstrap.` prefix on drain as every other bootstrap warning; the literal here is the bare suffix passed in, matching how `yocto-host`'s WARNING sibling (`yocto_mixed_warning`) is emitted the same way." - }, - { - "code": "bootstrap.workspace-invalid", - "status": "reserved", - "severity": "error", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to distinguish an unusable `--workspace ` value (empty/whitespace-only, or a Windows drive-relative root like `/e/foo/ws` that would otherwise resolve against whichever drive the process happens to run from) from the workspace-parent-guard refusal above -- this fires BEFORE any directory listing or IO, on the value itself (tan-cli#185 review finding 3).", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"workspace-invalid\"", - "note": "The `bootstrap.` prefix is applied by `failure()`, same as `workspace-guard` above. Emitted by `tan_core::path_guard::resolve_workspace_target`'s `Err` case; that function does no IO of its own, so this refusal never leaves anything on disk." - }, - { - "code": "bootstrap.print-env-workspace-conflict", - "status": "reserved", - "severity": "error", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to distinguish `--print-env --workspace ` (a combination `tan bootstrap` refuses outright rather than rendering env lines for a directory nothing was ever moved into) from every other bootstrap refusal (tan-cli#185 review finding 7).", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"print-env-workspace-conflict\"", - "note": "The `bootstrap.` prefix is applied by `failure()`, same as `workspace-guard` above. Fires before the `--print-env` short-circuit and before the workspace-parent guard itself -- `--print-env`'s whole contract is printing what an already-resolved workspace exports, and `--workspace` names where a NEW one goes; the two claims conflict regardless of what the checkout's parent holds." - }, - { - "code": "bootstrap.manifest", - "status": "reserved", - "severity": "error", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to recognise tan's own refusal of an unreadable or version-skewed `/metadata/bootstrap.json` (alp-sdk#917, tan-cli#99) and stop before spawning the real bootstrap a second time -- today an unrecognised code falls through and the customer watches the identical failure scroll past twice.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"manifest\"", - "note": "tan-cli#111: fires at the FIRST `load_facts(&sdk_root)` call, immediately after the SDK root itself resolves -- strictly BEFORE `select_workspace`, the workspace-parent guard (tan-cli#185), and any venv/west/pip phase. A doubled run therefore costs seconds, not minutes, and leaves nothing on disk: no `.venv`, no `.west`, no relocation." - }, - { - "code": "bootstrap.sdk-root-unresolved", - "status": "reserved", - "severity": "error", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to recognise tan's refusal to bootstrap with no alp-sdk root resolvable at all -- distinct from every prerequisite/manifest/workspace refusal in this registry, which all presuppose a resolved SDK root.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"sdk-root-unresolved\"", - "note": "tan-cli#111: the ONLY bootstrap refusal that predates project resolution -- it reports a null `project` (both `root` and `boardYaml`), unlike every other bootstrap issue code here. Already exercised, as a deliberately NON-matching example, in alp-sdk-vscode's own test suite (`test/alpCli.service.test.js`), which is how the review confirmed it carries no real `===` binding today." - }, - { - "code": "bootstrap.zephyr-base-manifest-mismatch", - "status": "reserved", - "severity": "warning", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to explain why an existing `$ZEPHYR_BASE` workspace was NOT reused: its Zephyr checkout is on the right pin but its west manifest is not alp-sdk's own `west.yml`, so reusing it would leave every `west alp-*` extension command unknown (tan-cli#769).", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"zephyr-base-manifest-mismatch\"", - "note": "tan-cli#111: recorded via `Log::warn` (steps.rs), the same drain + `bootstrap.` prefixing as `workspace-relocated` above. Fires from `select_workspace`, AFTER project resolution and the workspace-parent guard, once the west-topdir facts are known -- unlike `manifest` and `sdk-root-unresolved`, which both fire before it." - }, - { - "code": "bootstrap.python-not-runnable", - "status": "frozen", - "severity": "error", - "consumer": "alp-sdk-vscode src/alpCli/service.ts :: prerequisitesMissingIssue (PREREQ_CODES)", - "consumerEffect": "Stops the extension spawning the real bootstrap terminal after tan already refused because `python`/`python3` resolves on PATH but will not run (a Microsoft Store alias, or similar). Renamed, the customer watches the identical failure scroll past with the install guidance lost -- the same failure shape as `bootstrap.prerequisites-missing`.", - "emittedBy": "crates/tan-core/src/bootstrap/prerequisites.rs", - "literal": "code: \"python-not-runnable\"", - "note": "tan-cli#111: verified bound in the read-only alp-sdk-vscode checkout -- `PREREQ_CODES` (src/alpCli/service.ts) matches this code with `Set.has()`, equivalent to `===` for this purpose, alongside `bootstrap.prerequisites-missing`. Previously documented in contract/README.md as a workaround ('a consumer that wants those two must match them by name') instead of registered here; promoted to `frozen` because a real consumer already binds to it. Carries NO `missingPrerequisites[]` entry -- a `{tool, command}` pair cannot represent 'the Python you have will not run' -- so the fix travels only in `issues[].message`." - }, - { - "code": "bootstrap.python-too-old", - "status": "frozen", - "severity": "error", - "consumer": "alp-sdk-vscode src/alpCli/service.ts :: prerequisitesMissingIssue (PREREQ_CODES)", - "consumerEffect": "Stops the extension spawning the real bootstrap terminal after tan already refused because the resolved Python is below the SDK tooling's floor (currently >= 3.10). Renamed, the customer watches the identical failure scroll past with the install guidance lost.", - "emittedBy": "crates/tan-core/src/bootstrap/prerequisites.rs", - "literal": "code: \"python-too-old\"", - "note": "tan-cli#111: same consumer and the same `Set.has()` binding as `bootstrap.python-not-runnable`, verified in the same read-only checkout pass. Also tool-less: the tool IS present, it is the wrong version, so there is no `{tool, command}` pair and the install command travels in `issues[].message` instead." - }, - { - "code": "debug-config.legacy-entry-untouched", - "status": "reserved", - "severity": "info", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to surface a persistent notice that a leftover pre-#155 `\"ALP: ...\"` entry still sits alongside the maintained `\"Alp: ...\"` one this run updated -- so a customer whose real hand-filled values are stranded on the orphaned entry (the exact #133 symptom) has something to act on instead of `tan` reporting bare success.", - "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", - "literal": "\"debug-config.legacy-entry-untouched\"", - "note": "tan-cli#179: fires on the ORDINARY same-name merge path in `tan_core::debug_launch::create_launch_json_write_plan` -- an exact-name HIT against the current `\"Alp: ...\"` name -- whenever a legacy `\"ALP: ...\"` counterpart of the SAME draft ALSO still exists in the file. Distinct from `debug-config.legacy-entry-migrated`, which fires on the MISS path when the legacy entry is the one adopted. This branch deliberately still does not touch or delete the legacy entry (see `both_a_current_and_a_legacy_entry_leaves_the_legacy_one_untouched` in `crates/tan-core/src/debug_launch.rs`) -- nothing decides which of two possibly-hand-edited entries is authoritative -- it only stops being SILENT about it. Fires on every run while the leftover entry remains, not just once." - }, - { - "code": "bootstrap.zephyr-base-stale", - "status": "reserved", - "severity": "warning", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that an existing `$ZEPHYR_BASE` workspace belonging to this same SDK checkout was on an older Zephyr pin and is being refreshed in place with `west update` rather than reused untouched or abandoned for a second clone elsewhere.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"zephyr-base-stale\"", - "note": "tan-cli#111 registry audit: recorded via `Log::warn` (steps.rs), same drain + `bootstrap.` prefixing as `workspace-relocated`. Fires from `select_workspace`'s `WorkspaceChoice::Stale` arm, the sibling of `zephyr-base-manifest-mismatch` and `zephyr-base-incompatible` in the same match -- all three were reachable before this entry but only the mismatch case was registered." - }, - { - "code": "bootstrap.zephyr-base-incompatible", - "status": "reserved", - "severity": "warning", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that an ambient `$ZEPHYR_BASE` was ignored outright because it is not an alp-sdk Zephyr west workspace at all (wrong pin AND no recognisable manifest), distinct from the milder `zephyr-base-stale` (right manifest, wrong pin, refreshed in place) and `zephyr-base-manifest-mismatch` (right pin, wrong manifest) cases.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"zephyr-base-incompatible\"", - "note": "tan-cli#111 registry audit: recorded via `Log::warn` (steps.rs). Fires from `select_workspace`'s `WorkspaceChoice::Incompatible` arm; also clears `$ZEPHYR_BASE` from every child so the foreign tree cannot hijack `west init`, same as the mismatch case." - }, - { - "code": "bootstrap.west-config-reconciled", - "status": "reserved", - "severity": "warning", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that `tan bootstrap` rewrote a stale `.west/config` `manifest.path` that named a different SDK checkout under the same topdir (#31), before running `west update` against it.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"west-config-reconciled\"", - "note": "tan-cli#111 registry audit: recorded via `Log::warn`. Fires only when adopting (not reusing) an existing west topdir, immediately before the west init/update phase; its sibling `west-config-reconcile-failed` fires when the same reconcile attempt could not rewrite the pointer." - }, - { - "code": "bootstrap.west-config-reconcile-failed", - "status": "reserved", - "severity": "warning", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that `tan bootstrap` could not rewrite a stale `.west/config` `manifest.path` (#31) before `west update` runs -- the subsequent `west update` may then resolve the WRONG SDK's `west.yml`.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"west-config-reconcile-failed\"", - "note": "tan-cli#111 registry audit: recorded via `Log::warn`. The failure path also suppresses the workspace-sync record that would otherwise tell `tan sdk switch` this topdir is up to date -- see the comment above `record_workspace_sdk` in the same file." - }, - { - "code": "bootstrap.pip-upgrade", - "status": "reserved", - "severity": "warning", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that the venv's own `pip`/`wheel` self-upgrade reported a problem before the dependent Python installs (Zephyr requirements, SDK extras, the editable backend) ran.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/steps.rs", - "literal": "\"pip-upgrade\"", - "note": "tan-cli#111 registry audit: recorded via `Log::warn`, non-fatal like every pip-phase warning -- the run continues into `zephyr-requirements`/`sdk-extras`/`editable-install` regardless." - }, - { - "code": "bootstrap.zephyr-requirements", - "status": "reserved", - "severity": "warning", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that installing Zephyr's own `requirements.txt` into the venv reported a problem -- the customer's venv may be missing packages a Zephyr build later needs.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/steps.rs", - "literal": "\"zephyr-requirements\"", - "note": "tan-cli#111 registry audit: recorded via `Log::warn` in `pip_phase`, the same non-fatal pattern as `pip-upgrade`/`sdk-extras`/`editable-install`. Only fires when the SDK's Zephyr requirements file exists on disk." - }, - { - "code": "bootstrap.sdk-extras", - "status": "reserved", - "severity": "warning", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that installing alp-sdk's own Python extras (`jsonschema`, the MCUboot dev-key tooling) into the venv reported a problem.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/steps.rs", - "literal": "\"sdk-extras\"", - "note": "tan-cli#111 registry audit: recorded via `Log::warn` in `pip_phase`, same non-fatal pattern as `pip-upgrade`/`zephyr-requirements`/`editable-install`." - }, - { - "code": "bootstrap.editable-install", - "status": "reserved", - "severity": "warning", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that the editable `pip install -e` of tan's Python backend (`alp_cli`) into the venv reported a problem -- the venv may be left without a working backend for later `tan` invocations that shell into it.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/steps.rs", - "literal": "\"editable-install\"", - "note": "tan-cli#111 registry audit: recorded via `Log::warn` in `pip_phase`, the last of the three non-fatal pip-extras warnings alongside `sdk-extras`/`zephyr-requirements`." - }, - { - "code": "bootstrap.failed", - "status": "reserved", - "severity": "error", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to recognise the generic fatal-step failure `tan bootstrap` reports when a REQUIRED step (venv creation, `west init`/`update`, or a hard I/O error) fails outright, as opposed to the non-fatal `Log::warn` warnings above. Distinct from every `failure()`-emitted `bootstrap.` refusal in this registry: those fire before any step ran and report a `null`/pre-resolution project where relevant, this one keeps the resolved project + paths from however far the run got.", - "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", - "literal": "\"bootstrap.failed\"", - "note": "tan-cli#111 registry audit: unlike every other `bootstrap.*` code here, the full dotted string is written at the call site (`fatal()`) rather than a bare suffix prefixed by `failure()`/`Log::take_issues()` -- this is the one bootstrap code whose message varies per failure (whatever the failed step's own error was), so no single `consumerEffect` narrative fits every occurrence." - }, - { - "code": "debug-config.internal-failure", - "status": "reserved", - "severity": "error", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to recognise `tan debug-config`'s refusal of an invalid `--target-kind`/`--server` combination, or a malformed existing `.vscode/launch.json` the merge could not parse -- exits `InternalFailure` (5) with a `zephyr-mcu`/`none` placeholder target rather than the resolved one.", - "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", - "literal": "\"internal-failure\"", - "note": "tan-cli#111 registry audit: routed through the shared `failure_envelope` (the same `debug-config.` formatting `write-failure` uses); its sibling reserved codes (`legacy-entry-migrated`, `legacy-entry-untouched`, `comments-dropped`) are all `info`-severity success-path notices, not failures -- this registry had no `error`-severity debug-config entry at all before this audit." - }, - { - "code": "debug-config.write-failure", - "status": "reserved", - "severity": "error", - "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", - "consumerEffect": "None today. Reserved for alp-sdk-vscode to recognise `tan debug-config`'s refusal when creating `.vscode/` or writing `launch.json` itself hits a filesystem error (permissions, a read-only mount, disk full) -- exits `WriteFailure` (3), preserving the resolved target/server unlike `internal-failure`.", - "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", - "literal": "\"write-failure\"", - "note": "tan-cli#111 registry audit: same shared `failure_envelope` path as `internal-failure`; the two are distinguished only by which exit code and text lines `debug_config.rs` passes in, matching the write-vs-internal split `crates/tan-cli/src/exit.rs` documents." - }, - { - "code": "build.manifest-write-failed", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", - "literal": "code: \"build.manifest-write-failed\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "build.sdk-switch-pristine", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", - "literal": "code: \"build.sdk-switch-pristine\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "build.pristine-skipped", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", - "literal": "code: \"build.pristine-skipped\"", - "note": "Added by tan-cli#183: `tan build --pristine` has three paths that correctly decline to wipe (an overridden `-d`/`--build-dir`, a cwd outside `build/`, and a dir that was never configured) and all three used to be silent, so a customer who asked for a clean build got an incremental one and was told nothing. Registered `reserved` per tan-cli#219's rule that every literal issue code under crates/ appears in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "build.sdk-switch-pristine-failed", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", - "literal": "code: \"build.sdk-switch-pristine-failed\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "build.slice-failed", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", - "literal": "code: \"build.slice-failed\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "build.toolchain-root-unresolved", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/build/plan_modes.rs", - "literal": "code: \"build.toolchain-root-unresolved\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "build.unknown-backend", - "status": "reserved", - "severity": "error or warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", - "literal": "code: \"build.unknown-backend\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it. Severity is per-slice and policy-driven: error under executionPolicy.unknownBackend=fail, warning under skip." - }, - { - "code": "cli.parse-error", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/main.rs", - "literal": "code: \"cli.parse-error\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "completion.shell-unsupported", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/completion.rs", - "literal": "code: \"completion.shell-unsupported\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "doctor.internal-failure", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/doctor.rs", - "literal": "code: \"doctor.internal-failure\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "doctor.server-compatibility", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/doctor.rs", - "literal": "code: \"doctor.server-compatibility\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "envelope.serialize-failed", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/envelope.rs", - "literal": "code: \"envelope.serialize-failed\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "flash.boot-order-unknown-core", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/flash/mod.rs", - "literal": "code: \"flash.boot-order-unknown-core\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "flash.confirm-required", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/flash/mod.rs", - "literal": "code: \"flash.confirm-required\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "flash.entry-failed", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/flash/mod.rs", - "literal": "code: \"flash.entry-failed\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "flash.nothing-matched", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/flash/mod.rs", - "literal": "code: \"flash.nothing-matched\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "flash.slice-not-built", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/flash/mod.rs", - "literal": "code: \"flash.slice-not-built\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "generate.emit-failed", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/generate.rs", - "literal": "code: \"generate.emit-failed\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "image.bundle-write-failed", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/image.rs", - "literal": "code: \"image.bundle-write-failed\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "image.helper-missing", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/image.rs", - "literal": "code: \"image.helper-missing\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "image.helper-skipped", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/image.rs", - "literal": "code: \"image.helper-skipped\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "image.slice-skipped", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/image.rs", - "literal": "code: \"image.slice-skipped\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "image.slice-unsafe-name", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/image.rs", - "literal": "code: \"image.slice-unsafe-name\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "init.would-overwrite", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/init/from_example.rs", - "literal": "code: \"init.would-overwrite\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "init.write-failed", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/init/response.rs", - "literal": "code: \"init.write-failed\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "inspect.board-yaml-missing", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/inspect.rs", - "literal": "code: \"inspect.board-yaml-missing\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "inspect.path-not-found", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/inspect.rs", - "literal": "code: \"inspect.path-not-found\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "pinmux.no-target", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", - "literal": "code: \"pinmux.no-target\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "pinmux.schema-version-unsupported", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", - "literal": "code: \"pinmux.schema-version-unsupported\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "pinmux.sdk-root-unresolved", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", - "literal": "code: \"pinmux.sdk-root-unresolved\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "pinmux.table-empty", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", - "literal": "code: \"pinmux.table-empty\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "pinmux.table-not-found", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", - "literal": "code: \"pinmux.table-not-found\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "pinmux.unknown-sku", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", - "literal": "code: \"pinmux.unknown-sku\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "scaffold.would-overwrite", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/scaffold.rs", - "literal": "code: \"scaffold.would-overwrite\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "scaffold.write-failed", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/scaffold.rs", - "literal": "code: \"scaffold.write-failed\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "sdk.bootstrap-recommended", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/sdk.rs", - "literal": "code: \"sdk.bootstrap-recommended\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "sdk.install-not-ready", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/sdk.rs", - "literal": "code: \"sdk.install-not-ready\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "sdk.west-config-not-reconciled", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/sdk.rs", - "literal": "code: \"sdk.west-config-not-reconciled\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "sdk.west-config-reconcile-failed", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/sdk.rs", - "literal": "code: \"sdk.west-config-reconcile-failed\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "sdk.west-config-reconciled", - "status": "reserved", - "severity": "warning", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/sdk.rs", - "literal": "code: \"sdk.west-config-reconciled\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "support-bundle.internal-failure", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/support_bundle.rs", - "literal": "code: \"support-bundle.internal-failure\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - }, - { - "code": "support-bundle.server-compatibility", - "status": "reserved", - "severity": "error", - "consumer": "none", - "emittedBy": "crates/tan-cli/src/commands/support_bundle.rs", - "literal": "code: \"support-bundle.server-compatibility\"", - "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." - } - ] -} +{ + "schemaVersion": 1, + "_comment": [ + "FROZEN issue codes: the exact `issues[].code` strings alp-sdk-vscode", + "matches with `===` to gate real behaviour. Every one of those matches", + "FAILS OPEN -- an unrecognised code is indistinguishable from 'no", + "problem', so a rename here is silent on both sides with CI green. See", + "alplabai/tan-cli#106.", + "", + "This file is the single source: `crates/tan-cli/tests/contract.rs`", + "(`frozen_issue_codes`) gates it against the emitting sources, and the", + "release workflow folds it into the published `envelope-contract.json`", + "asset so the extension's own contract test can diff against an artefact", + "instead of a hand-copied fixture.", + "", + "Adding a `frozen` code is cheap. REMOVING or RENAMING one is a breaking", + "wire change: bump the CLI MAJOR/MINOR, say so in CHANGELOG.md, and open", + "the matching issue on alp-sdk-vscode. Do not 'fix' a rename by", + "loosening the consumer to a prefix match -- `bootstrap.` would swallow", + "codes it has no verdict for.", + "", + "A `reserved` code is the pre-consumer state: the spelling exists at the", + "emission site (the gate still checks that) but `consumer` is \"none\" --", + "nobody matches it with `===` yet, so renaming or dropping it costs", + "nothing on the wire. Promote a `reserved` code to `frozen` the moment a", + "consumer binds to it (fill in `consumer`/`consumerEffect` for real); do", + "not invent a third status for that transition.", + "", + "EVERY literal emit site must appear here at some status (tan-cli#219).", + "`frozen_issue_codes` only ever walked registry -> source, so a code that", + "was never registered was ungated on BOTH sides at once: this repo's", + "checks iterate the registry and never saw it, and alp-sdk-vscode's gate", + "keys off the published artefact, which is built from this same registry.", + "A rename of an unregistered code was invisible to both repos", + "simultaneously. `every_emitted_issue_code_is_registered` walks the other", + "way and fails when an emitted code has no entry. 41 codes were in that", + "state when it landed; they are `reserved`, NOT `frozen` -- freezing what", + "no consumer reads would over-commit and make every future internal", + "rename a contract break for nobody's benefit.", + "", + "The published `envelope-contract.json` carries this array WHOLE, all", + "three statuses, not a frozen-only subset -- so the artefact's code list", + "is everything tan emits, and a consumer reads `status` to decide what a", + "code promises. Keep it that way: a silently-partial artefact that", + "presents itself as the contract is worse than either honest option." + ], + "issueCodes": [ + { + "code": "bootstrap.windows-unsupported", + "status": "retired", + "severity": "error", + "consumer": "alp-sdk-vscode src/alpCli/service.ts :: bootstrapHostVerdict", + "consumerEffect": "Refuses the Windows bootstrap and offers 'Reopen in WSL'.", + "note": "Emitted by tan v0.3.0 and EARLIER only (the retired commands/bootstrap.rs, which shelled the SDK's POSIX bootstrap.sh). Native Windows bootstrap shipped in v0.3.1, so current tan never emits it -- but the consumer branch is permanent back-compat for anyone pinned to an old binary via alpSdk.cliPath. RESERVED: this spelling must never be reused for a different verdict, which is what the gate asserts." + }, + { + "code": "bootstrap.yocto-host", + "status": "frozen", + "severity": "error", + "consumer": "alp-sdk-vscode src/alpCli/service.ts :: bootstrapHostVerdict", + "consumerEffect": "Refuses to bootstrap a Yocto-only project on a non-Linux host. Renamed, the project is sent into a bootstrap that cannot work here.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"yocto-host\"", + "note": "The consumer ALSO requires severity 'error'. The mixed-board WARNING reuses this same suffix at severity 'warning' and must stay a warning -- promoting it would refuse a board that can bootstrap its Zephyr cores." + }, + { + "code": "bootstrap.prerequisites-missing", + "status": "frozen", + "severity": "error", + "consumer": "alp-sdk-vscode src/alpCli/service.ts :: prerequisitesMissingIssue", + "consumerEffect": "Stops the extension spawning the real bootstrap terminal after tan already refused. Renamed, the customer watches the identical failure scroll past with the install guidance lost.", + "emittedBy": "crates/tan-core/src/bootstrap/prerequisites.rs", + "literal": "code: \"prerequisites-missing\"", + "note": "The `bootstrap.` prefix is applied by `failure()` in crates/tan-cli/src/commands/bootstrap/mod.rs. NOT the only refusal from that gate: `bootstrap.python-not-runnable` and `bootstrap.python-too-old` are separate codes (no missing TOOL to report) and a consumer wanting those two must match them by name." + }, + { + "code": "presets.sdk-root-unresolved", + "status": "frozen", + "severity": "warning", + "consumer": "alp-sdk-vscode src/ideHub/newProjectFlowPanel.ts", + "consumerEffect": "Warns that the Hardware list carries no core topology. Renamed, the New Project wizard silently falls back to its static E1M_MODULES catalogue and a HETEROGENEOUS SoM scaffolds single-core with no IPC -- the reference part E1M-AEN801 is multi-core, so that is the default path.", + "emittedBy": "crates/tan-cli/src/commands/presets.rs", + "literal": "\"presets.sdk-root-unresolved\"", + "note": "Also pinned end-to-end by the `presets-no-sdk` golden envelope, which is the stronger gate: it asserts the code actually reaches the wire, not just that the string survives in the source." + }, + { + "code": "debug-config.legacy-entry-migrated", + "status": "reserved", + "severity": "info", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to surface a one-time notice that a stale pre-#155 `\"ALP: ...\"` launch-configuration entry was folded into the maintained `\"Alp: ...\"` one: any hand-resolved value on an unresolved-placeholder field (device, miDebuggerServerAddress, configFiles, ...) the customer had filled in on the orphan carried across, while every other field tan owns was refreshed to this run's values (tan-cli#133, reopened).", + "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", + "literal": "\"debug-config.legacy-entry-migrated\"", + "note": "Fires only on the one-time migration path in `tan_core::debug_launch::create_launch_json_write_plan`: an exact-name miss against the current `\"Alp: ...\"` name that then hits the ONE legacy spelling of that same name. It never fires when a current-named entry already exists (whether or not a legacy one also still sits in the file) -- that branch deliberately leaves any leftover legacy entry untouched rather than guessing which of two possibly-hand-edited entries is authoritative." + }, + { + "code": "debug-config.comments-dropped", + "status": "reserved", + "severity": "info", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to surface a one-time notice that a `tan debug-config` write discarded a comment (or trailing comma) sitting inside the byte span it rewrote -- the one launch-configuration entry a splice replaced, or, on the whole-document fallback, the customer's entire original file (tan-cli#182 review finding #2).", + "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", + "literal": "\"debug-config.comments-dropped\"", + "note": "Set from `LaunchJsonWritePlan::comments_dropped` (`tan_core::debug_launch::write_content`): for a splice, whether `strip_jsonc` changes the replaced entry's own original byte span; for the whole-document fallback, whether it changes the original file at all. Never fires on the no-op short-circuit path (an unchanged merge returns `original` verbatim) or on an append (nothing existing is ever rewritten)." + }, + { + "code": "debug-config.sdk-identity-overwrite", + "status": "reserved", + "severity": "info", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to surface a one-time notice that a `tan debug-config` write just replaced a concrete existing `device`/`targetId`/`configFiles` value with one resolved from the SDK's published per-variant debug-probe identity (alp-sdk#987) rather than from a real build (alp-sdk#1026 review finding #1).", + "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", + "literal": "\"debug-config.sdk-identity-overwrite\"", + "note": "Set from `tan_core::debug_launch::sdk_identity_overwrites`, called only for the field(s) `fill_debug_probe_identity_from_sdk` itself populated this run (never for a field a real build's `runners.yaml` already resolved -- that overwrite is pre-existing, intended behaviour per `merge_configuration`'s own doc comment, not something this code is scoped to disclose). Fires once per overwritten field, only on the write path (never `--preview`, which never reads or merges into the existing file at all)." + }, + { + "code": "debug-config.sdk-identity-key-absent", + "status": "reserved", + "severity": "info", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to distinguish 'this SoM's SDK-published debug-probe identity exists but does not include a value for this server's field yet' (e.g. every Alif variant today, for `openocd_config`) from the generic 'still needs resolution' case, which fires for the same reason a pre-build project has no `device` at all.", + "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", + "literal": "\"debug-config.sdk-identity-key-absent\"", + "note": "alp-sdk#1026 review finding #4: the generic 'Placeholder fields...' preview note is real but unspecific, and names `device` even for e.g. an OpenOCD draft that carries no `device` key at all. Fires when `fill_debug_probe_identity_from_sdk` found a `variants[].debug` block for the resolved SoC variant but the field `server_identity_field` maps to this server is still an unresolved placeholder in the draft. Emitted on BOTH `--preview` and a write -- this is advisory about resolution state, not about what a write changed on disk, unlike its `sdk-identity-overwrite` sibling above." + }, + { + "code": "bootstrap.workspace-guard", + "status": "reserved", + "severity": "error", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to distinguish the tan-cli#185 workspace-parent-guard refusal (the checkout's parent holds unrelated content and neither --workspace nor an interactive accept resolved it) from every other bootstrap refusal, so a future UI could offer its own relocation picker instead of just showing the message.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/relocate.rs", + "literal": "code: \"workspace-guard\"", + "note": "The `bootstrap.` prefix is applied by `failure()` in crates/tan-cli/src/commands/bootstrap/mod.rs, same as `prerequisites-missing` above. Fires on BOTH a --non-interactive/--ci/--format json refusal and an interactive decline/cancel -- the two share this one code, distinguished only by `exitCode` (2 vs 1) and by `issues[].message`, which is what a consumer without a code-level split reads instead." + }, + { + "code": "bootstrap.workspace-relocated", + "status": "reserved", + "severity": "warning", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that `tan bootstrap` physically moved the customer's alp-sdk checkout (an accepted tan-cli#185 relocation prompt, or an explicit --workspace naming somewhere new) -- `data.sdkRoot`/`data.workspaceDir` already carry the new location on the wire; this is the narrative flag that it MOVED rather than simply having always been there.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"workspace-relocated\"", + "note": "Recorded via `Log::warn` (steps.rs), which applies the same `bootstrap.` prefix on drain as every other bootstrap warning; the literal here is the bare suffix passed in, matching how `yocto-host`'s WARNING sibling (`yocto_mixed_warning`) is emitted the same way." + }, + { + "code": "bootstrap.workspace-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to distinguish an unusable `--workspace ` value (empty/whitespace-only, or a Windows drive-relative root like `/e/foo/ws` that would otherwise resolve against whichever drive the process happens to run from) from the workspace-parent-guard refusal above -- this fires BEFORE any directory listing or IO, on the value itself (tan-cli#185 review finding 3).", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"workspace-invalid\"", + "note": "The `bootstrap.` prefix is applied by `failure()`, same as `workspace-guard` above. Emitted by `tan_core::path_guard::resolve_workspace_target`'s `Err` case; that function does no IO of its own, so this refusal never leaves anything on disk." + }, + { + "code": "bootstrap.print-env-workspace-conflict", + "status": "reserved", + "severity": "error", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to distinguish `--print-env --workspace ` (a combination `tan bootstrap` refuses outright rather than rendering env lines for a directory nothing was ever moved into) from every other bootstrap refusal (tan-cli#185 review finding 7).", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"print-env-workspace-conflict\"", + "note": "The `bootstrap.` prefix is applied by `failure()`, same as `workspace-guard` above. Fires before the `--print-env` short-circuit and before the workspace-parent guard itself -- `--print-env`'s whole contract is printing what an already-resolved workspace exports, and `--workspace` names where a NEW one goes; the two claims conflict regardless of what the checkout's parent holds." + }, + { + "code": "bootstrap.manifest", + "status": "reserved", + "severity": "error", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to recognise tan's own refusal of an unreadable or version-skewed `/metadata/bootstrap.json` (alp-sdk#917, tan-cli#99) and stop before spawning the real bootstrap a second time -- today an unrecognised code falls through and the customer watches the identical failure scroll past twice.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"manifest\"", + "note": "tan-cli#111: fires at the FIRST `load_facts(&sdk_root)` call, immediately after the SDK root itself resolves -- strictly BEFORE `select_workspace`, the workspace-parent guard (tan-cli#185), and any venv/west/pip phase. A doubled run therefore costs seconds, not minutes, and leaves nothing on disk: no `.venv`, no `.west`, no relocation." + }, + { + "code": "bootstrap.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to recognise tan's refusal to bootstrap with no alp-sdk root resolvable at all -- distinct from every prerequisite/manifest/workspace refusal in this registry, which all presuppose a resolved SDK root.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"sdk-root-unresolved\"", + "note": "tan-cli#111: the ONLY bootstrap refusal that predates project resolution -- it reports a null `project` (both `root` and `boardYaml`), unlike every other bootstrap issue code here. Already exercised, as a deliberately NON-matching example, in alp-sdk-vscode's own test suite (`test/alpCli.service.test.js`), which is how the review confirmed it carries no real `===` binding today." + }, + { + "code": "bootstrap.zephyr-base-manifest-mismatch", + "status": "reserved", + "severity": "warning", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to explain why an existing `$ZEPHYR_BASE` workspace was NOT reused: its Zephyr checkout is on the right pin but its west manifest is not alp-sdk's own `west.yml`, so reusing it would leave every `west alp-*` extension command unknown (tan-cli#769).", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"zephyr-base-manifest-mismatch\"", + "note": "tan-cli#111: recorded via `Log::warn` (steps.rs), the same drain + `bootstrap.` prefixing as `workspace-relocated` above. Fires from `select_workspace`, AFTER project resolution and the workspace-parent guard, once the west-topdir facts are known -- unlike `manifest` and `sdk-root-unresolved`, which both fire before it." + }, + { + "code": "bootstrap.python-not-runnable", + "status": "frozen", + "severity": "error", + "consumer": "alp-sdk-vscode src/alpCli/service.ts :: prerequisitesMissingIssue (PREREQ_CODES)", + "consumerEffect": "Stops the extension spawning the real bootstrap terminal after tan already refused because `python`/`python3` resolves on PATH but will not run (a Microsoft Store alias, or similar). Renamed, the customer watches the identical failure scroll past with the install guidance lost -- the same failure shape as `bootstrap.prerequisites-missing`.", + "emittedBy": "crates/tan-core/src/bootstrap/prerequisites.rs", + "literal": "code: \"python-not-runnable\"", + "note": "tan-cli#111: verified bound in the read-only alp-sdk-vscode checkout -- `PREREQ_CODES` (src/alpCli/service.ts) matches this code with `Set.has()`, equivalent to `===` for this purpose, alongside `bootstrap.prerequisites-missing`. Previously documented in contract/README.md as a workaround ('a consumer that wants those two must match them by name') instead of registered here; promoted to `frozen` because a real consumer already binds to it. Carries NO `missingPrerequisites[]` entry -- a `{tool, command}` pair cannot represent 'the Python you have will not run' -- so the fix travels only in `issues[].message`." + }, + { + "code": "bootstrap.python-too-old", + "status": "frozen", + "severity": "error", + "consumer": "alp-sdk-vscode src/alpCli/service.ts :: prerequisitesMissingIssue (PREREQ_CODES)", + "consumerEffect": "Stops the extension spawning the real bootstrap terminal after tan already refused because the resolved Python is below the SDK tooling's floor (currently >= 3.10). Renamed, the customer watches the identical failure scroll past with the install guidance lost.", + "emittedBy": "crates/tan-core/src/bootstrap/prerequisites.rs", + "literal": "code: \"python-too-old\"", + "note": "tan-cli#111: same consumer and the same `Set.has()` binding as `bootstrap.python-not-runnable`, verified in the same read-only checkout pass. Also tool-less: the tool IS present, it is the wrong version, so there is no `{tool, command}` pair and the install command travels in `issues[].message` instead." + }, + { + "code": "debug-config.legacy-entry-untouched", + "status": "reserved", + "severity": "info", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to surface a persistent notice that a leftover pre-#155 `\"ALP: ...\"` entry still sits alongside the maintained `\"Alp: ...\"` one this run updated -- so a customer whose real hand-filled values are stranded on the orphaned entry (the exact #133 symptom) has something to act on instead of `tan` reporting bare success.", + "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", + "literal": "\"debug-config.legacy-entry-untouched\"", + "note": "tan-cli#179: fires on the ORDINARY same-name merge path in `tan_core::debug_launch::create_launch_json_write_plan` -- an exact-name HIT against the current `\"Alp: ...\"` name -- whenever a legacy `\"ALP: ...\"` counterpart of the SAME draft ALSO still exists in the file. Distinct from `debug-config.legacy-entry-migrated`, which fires on the MISS path when the legacy entry is the one adopted. This branch deliberately still does not touch or delete the legacy entry (see `both_a_current_and_a_legacy_entry_leaves_the_legacy_one_untouched` in `crates/tan-core/src/debug_launch.rs`) -- nothing decides which of two possibly-hand-edited entries is authoritative -- it only stops being SILENT about it. Fires on every run while the leftover entry remains, not just once." + }, + { + "code": "bootstrap.zephyr-base-stale", + "status": "reserved", + "severity": "warning", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that an existing `$ZEPHYR_BASE` workspace belonging to this same SDK checkout was on an older Zephyr pin and is being refreshed in place with `west update` rather than reused untouched or abandoned for a second clone elsewhere.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"zephyr-base-stale\"", + "note": "tan-cli#111 registry audit: recorded via `Log::warn` (steps.rs), same drain + `bootstrap.` prefixing as `workspace-relocated`. Fires from `select_workspace`'s `WorkspaceChoice::Stale` arm, the sibling of `zephyr-base-manifest-mismatch` and `zephyr-base-incompatible` in the same match -- all three were reachable before this entry but only the mismatch case was registered." + }, + { + "code": "bootstrap.zephyr-base-incompatible", + "status": "reserved", + "severity": "warning", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that an ambient `$ZEPHYR_BASE` was ignored outright because it is not an alp-sdk Zephyr west workspace at all (wrong pin AND no recognisable manifest), distinct from the milder `zephyr-base-stale` (right manifest, wrong pin, refreshed in place) and `zephyr-base-manifest-mismatch` (right pin, wrong manifest) cases.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"zephyr-base-incompatible\"", + "note": "tan-cli#111 registry audit: recorded via `Log::warn` (steps.rs). Fires from `select_workspace`'s `WorkspaceChoice::Incompatible` arm; also clears `$ZEPHYR_BASE` from every child so the foreign tree cannot hijack `west init`, same as the mismatch case." + }, + { + "code": "bootstrap.west-config-reconciled", + "status": "reserved", + "severity": "warning", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that `tan bootstrap` rewrote a stale `.west/config` `manifest.path` that named a different SDK checkout under the same topdir (#31), before running `west update` against it.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"west-config-reconciled\"", + "note": "tan-cli#111 registry audit: recorded via `Log::warn`. Fires only when adopting (not reusing) an existing west topdir, immediately before the west init/update phase; its sibling `west-config-reconcile-failed` fires when the same reconcile attempt could not rewrite the pointer." + }, + { + "code": "bootstrap.west-config-reconcile-failed", + "status": "reserved", + "severity": "warning", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that `tan bootstrap` could not rewrite a stale `.west/config` `manifest.path` (#31) before `west update` runs -- the subsequent `west update` may then resolve the WRONG SDK's `west.yml`.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"west-config-reconcile-failed\"", + "note": "tan-cli#111 registry audit: recorded via `Log::warn`. The failure path also suppresses the workspace-sync record that would otherwise tell `tan sdk switch` this topdir is up to date -- see the comment above `record_workspace_sdk` in the same file." + }, + { + "code": "bootstrap.pip-upgrade", + "status": "reserved", + "severity": "warning", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that the venv's own `pip`/`wheel` self-upgrade reported a problem before the dependent Python installs (Zephyr requirements, SDK extras, the editable backend) ran.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/steps.rs", + "literal": "\"pip-upgrade\"", + "note": "tan-cli#111 registry audit: recorded via `Log::warn`, non-fatal like every pip-phase warning -- the run continues into `zephyr-requirements`/`sdk-extras`/`editable-install` regardless." + }, + { + "code": "bootstrap.zephyr-requirements", + "status": "reserved", + "severity": "warning", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that installing Zephyr's own `requirements.txt` into the venv reported a problem -- the customer's venv may be missing packages a Zephyr build later needs.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/steps.rs", + "literal": "\"zephyr-requirements\"", + "note": "tan-cli#111 registry audit: recorded via `Log::warn` in `pip_phase`, the same non-fatal pattern as `pip-upgrade`/`sdk-extras`/`editable-install`. Only fires when the SDK's Zephyr requirements file exists on disk." + }, + { + "code": "bootstrap.sdk-extras", + "status": "reserved", + "severity": "warning", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that installing alp-sdk's own Python extras (`jsonschema`, the MCUboot dev-key tooling) into the venv reported a problem.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/steps.rs", + "literal": "\"sdk-extras\"", + "note": "tan-cli#111 registry audit: recorded via `Log::warn` in `pip_phase`, same non-fatal pattern as `pip-upgrade`/`zephyr-requirements`/`editable-install`." + }, + { + "code": "bootstrap.editable-install", + "status": "reserved", + "severity": "warning", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to notice that the editable `pip install -e` of tan's Python backend (`alp_cli`) into the venv reported a problem -- the venv may be left without a working backend for later `tan` invocations that shell into it.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/steps.rs", + "literal": "\"editable-install\"", + "note": "tan-cli#111 registry audit: recorded via `Log::warn` in `pip_phase`, the last of the three non-fatal pip-extras warnings alongside `sdk-extras`/`zephyr-requirements`." + }, + { + "code": "bootstrap.failed", + "status": "reserved", + "severity": "error", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to recognise the generic fatal-step failure `tan bootstrap` reports when a REQUIRED step (venv creation, `west init`/`update`, or a hard I/O error) fails outright, as opposed to the non-fatal `Log::warn` warnings above. Distinct from every `failure()`-emitted `bootstrap.` refusal in this registry: those fire before any step ran and report a `null`/pre-resolution project where relevant, this one keeps the resolved project + paths from however far the run got.", + "emittedBy": "crates/tan-cli/src/commands/bootstrap/mod.rs", + "literal": "\"bootstrap.failed\"", + "note": "tan-cli#111 registry audit: unlike every other `bootstrap.*` code here, the full dotted string is written at the call site (`fatal()`) rather than a bare suffix prefixed by `failure()`/`Log::take_issues()` -- this is the one bootstrap code whose message varies per failure (whatever the failed step's own error was), so no single `consumerEffect` narrative fits every occurrence." + }, + { + "code": "debug-config.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to recognise `tan debug-config`'s refusal of an invalid `--target-kind`/`--server` combination, or a malformed existing `.vscode/launch.json` the merge could not parse -- exits `InternalFailure` (5) with a `zephyr-mcu`/`none` placeholder target rather than the resolved one.", + "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", + "literal": "\"internal-failure\"", + "note": "tan-cli#111 registry audit: routed through the shared `failure_envelope` (the same `debug-config.` formatting `write-failure` uses); its sibling reserved codes (`legacy-entry-migrated`, `legacy-entry-untouched`, `comments-dropped`) are all `info`-severity success-path notices, not failures -- this registry had no `error`-severity debug-config entry at all before this audit." + }, + { + "code": "debug-config.write-failure", + "status": "reserved", + "severity": "error", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "consumerEffect": "None today. Reserved for alp-sdk-vscode to recognise `tan debug-config`'s refusal when creating `.vscode/` or writing `launch.json` itself hits a filesystem error (permissions, a read-only mount, disk full) -- exits `WriteFailure` (3), preserving the resolved target/server unlike `internal-failure`.", + "emittedBy": "crates/tan-cli/src/commands/debug_config.rs", + "literal": "\"write-failure\"", + "note": "tan-cli#111 registry audit: same shared `failure_envelope` path as `internal-failure`; the two are distinguished only by which exit code and text lines `debug_config.rs` passes in, matching the write-vs-internal split `crates/tan-cli/src/exit.rs` documents." + }, + { + "code": "build.manifest-write-failed", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", + "literal": "code: \"build.manifest-write-failed\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.sdk-switch-pristine", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", + "literal": "code: \"build.sdk-switch-pristine\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.pristine-skipped", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", + "literal": "code: \"build.pristine-skipped\"", + "note": "Added by tan-cli#183: `tan build --pristine` has three paths that correctly decline to wipe (an overridden `-d`/`--build-dir`, a cwd outside `build/`, and a dir that was never configured) and all three used to be silent, so a customer who asked for a clean build got an incremental one and was told nothing. Registered `reserved` per tan-cli#219's rule that every literal issue code under crates/ appears in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.sdk-switch-pristine-failed", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", + "literal": "code: \"build.sdk-switch-pristine-failed\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.slice-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", + "literal": "code: \"build.slice-failed\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.toolchain-root-unresolved", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/build/plan_modes.rs", + "literal": "code: \"build.toolchain-root-unresolved\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.unknown-backend", + "status": "reserved", + "severity": "error or warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/build/execute/mod.rs", + "literal": "code: \"build.unknown-backend\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it. Severity is per-slice and policy-driven: error under executionPolicy.unknownBackend=fail, warning under skip." + }, + { + "code": "cli.parse-error", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/main.rs", + "literal": "code: \"cli.parse-error\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "completion.shell-unsupported", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/completion.rs", + "literal": "code: \"completion.shell-unsupported\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/doctor.rs", + "literal": "code: \"doctor.internal-failure\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.server-compatibility", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/doctor.rs", + "literal": "code: \"doctor.server-compatibility\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "envelope.serialize-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/envelope.rs", + "literal": "code: \"envelope.serialize-failed\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.boot-order-unknown-core", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/flash/mod.rs", + "literal": "code: \"flash.boot-order-unknown-core\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.confirm-required", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/flash/mod.rs", + "literal": "code: \"flash.confirm-required\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.entry-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/flash/mod.rs", + "literal": "code: \"flash.entry-failed\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.nothing-matched", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/flash/mod.rs", + "literal": "code: \"flash.nothing-matched\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.slice-not-built", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/flash/mod.rs", + "literal": "code: \"flash.slice-not-built\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.emit-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/generate.rs", + "literal": "code: \"generate.emit-failed\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.bundle-write-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/image.rs", + "literal": "code: \"image.bundle-write-failed\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.helper-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/image.rs", + "literal": "code: \"image.helper-missing\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.helper-skipped", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/image.rs", + "literal": "code: \"image.helper-skipped\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.slice-skipped", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/image.rs", + "literal": "code: \"image.slice-skipped\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.slice-unsafe-name", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/image.rs", + "literal": "code: \"image.slice-unsafe-name\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.would-overwrite", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/init/from_example.rs", + "literal": "code: \"init.would-overwrite\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.write-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/init/response.rs", + "literal": "code: \"init.write-failed\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "inspect.board-yaml-missing", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/inspect.rs", + "literal": "code: \"inspect.board-yaml-missing\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "inspect.path-not-found", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/inspect.rs", + "literal": "code: \"inspect.path-not-found\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "pinmux.no-target", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", + "literal": "code: \"pinmux.no-target\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "pinmux.schema-version-unsupported", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", + "literal": "code: \"pinmux.schema-version-unsupported\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "pinmux.sdk-root-unresolved", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", + "literal": "code: \"pinmux.sdk-root-unresolved\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "pinmux.table-empty", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", + "literal": "code: \"pinmux.table-empty\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "pinmux.table-not-found", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", + "literal": "code: \"pinmux.table-not-found\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "pinmux.unknown-sku", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/pinmux.rs", + "literal": "code: \"pinmux.unknown-sku\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "scaffold.would-overwrite", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/scaffold.rs", + "literal": "code: \"scaffold.would-overwrite\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "scaffold.write-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/scaffold.rs", + "literal": "code: \"scaffold.write-failed\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.bootstrap-recommended", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/sdk.rs", + "literal": "code: \"sdk.bootstrap-recommended\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.install-not-ready", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/sdk.rs", + "literal": "code: \"sdk.install-not-ready\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.west-config-not-reconciled", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/sdk.rs", + "literal": "code: \"sdk.west-config-not-reconciled\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.west-config-reconcile-failed", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/sdk.rs", + "literal": "code: \"sdk.west-config-reconcile-failed\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.west-config-reconciled", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/sdk.rs", + "literal": "code: \"sdk.west-config-reconciled\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/support_bundle.rs", + "literal": "code: \"support-bundle.internal-failure\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.server-compatibility", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "crates/tan-cli/src/commands/support_bundle.rs", + "literal": "code: \"support-bundle.server-compatibility\"", + "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.enclosing-west-workspace", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/bootstrap_cmd.py", + "literal": "\"enclosing-west-workspace\"", + "note": "The `bootstrap.` prefix is applied by `_refusal()`; fires when the intended west topdir sits under an ANCESTOR directory that already has its own `.west` (tan-cli#284's `enclosing_west_workspace_refusal`), distinct from the `workspace-guard` sibling above it (an OCCUPIED relocation target, not an ancestor workspace). Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/bootstrap_cmd.py", + "literal": "\"internal-failure\"", + "note": "Two sites share this spelling: `_refusal(ExitCode.INTERNAL_FAILURE, \"internal-failure\", ...)` for the unreachable `check_prerequisites` fallthrough, and a literal `Issue(\"bootstrap.internal-failure\", \"error\", ...)` in the command's own catch-all `except Exception` backstop. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.python-floor-skew", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/core/bootstrap.py", + "literal": "\"python-floor-skew\"", + "note": "Built by `python_floor_skew_warning()` as a bare `(code, message)` pair, prefixed to `bootstrap.` when `Log.warn(*skew)` drains it; fires whenever the manifest's declared `pythonMinVersion` and the effective (Zephyr-enforced) floor disagree, success or not (tan-cli#300). Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.python-newer-than-verified", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/core/bootstrap.py", + "literal": "\"python-newer-than-verified\"", + "note": "Built by `python_ceiling_warning()`, prefixed via `Log.warn(*ceiling)`; warns (never refuses) when the resolved interpreter is newer than `PYTHON_CEILING_KNOWN_GOOD` (tan-cli#285's other half -- a too-NEW Python is not a guaranteed failure the way too-OLD is). Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.venv-unusable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/core/bootstrap.py", + "literal": "\"venv-unusable\"", + "note": "Built by `posix_venv_unusable()` (Linux only: `python3` runs but its `venv` module cannot create a usable environment because `ensurepip`/`python3-venv` is missing, tan-cli#161/#294); forwarded to the wire through TWO sites -- `bootstrap_cmd.py`'s `Issue(f\"bootstrap.{refusal.code}\", ...)` and `doctor_cmd.py`'s `code=f\"bootstrap.{venv_refusal.code}\"` -- both prefixing the same bare `PrereqFailure.code`. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.workspace-relocation-rolled-back", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/bootstrap_cmd.py", + "literal": "\"workspace-relocation-rolled-back\"", + "note": "tan-cli#284: fires from `rollback_relocation_after()` when a LATER phase (venv/west) fails after the checkout was already relocated, and the rollback itself is reported -- whether the move-back and the pointer restore both succeeded, only the pointer restore failed, or the move-back itself could not complete. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "cli.command-deferred", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/deferred_cmd.py", + "literal": "DEFERRED_ISSUE_CODE = \"cli.command-deferred\"", + "note": "tan-cli#260: shared by all seven verbs this build stubs (`scaffold`, `completion`, `diff`, `pinmux`, `inspect`, `trace`, `support-bundle`), each real in the v0.4.1 oracle and deferred to v0.6.0. Assigned to a module constant and referenced by name at the `Issue(...)` call site, not spelled inline -- see this module's own docstring for why one shared code, not seven, and why `contract/` being open again is what unblocks promoting this note. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.internal-failure\"", + "note": "The port's catch-all `except Exception` backstop -- an uncaught exception reported as a coded envelope instead of a bare traceback. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.missing-tool", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.missing-tool\"", + "note": "Severity is `\"error\"` when the slice actually failed and `\"warning\"` when it was only skipped -- both share this one code, distinguished by `issues[].severity`. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.nothing-built", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.nothing-built\"", + "note": "Every slice was skipped rather than any slice failing outright -- a distinct code from the `build.slice-failed` sibling beside it. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.manifest-unreadable", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.manifest-unreadable\"", + "note": "Best-effort: an unreadable or unparsable system-manifest.yaml is a warning, never fatal -- `clean` must not fail over a manifest it only consults for an optimisation. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.remove-failed", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.remove-failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.sdk-root-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.sdk-root-not-found\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.unsafe-build-root", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.unsafe-build-root\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.unsafe-target", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.unsafe-target\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "examples.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/examples_cmd.py", + "literal": "\"examples.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.nothing-flashed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.nothing-flashed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.sdk-root-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.sdk-root-not-found\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.slice-skipped", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.slice-skipped\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.in-process-unavailable", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.in-process-unavailable\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/image_cmd.py", + "literal": "\"image.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.example-missing-board-yaml", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.example-missing-board-yaml\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.emit-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "code=\"kconfig.emit-failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.no-sdk-root", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "code=\"kconfig.no-sdk-root\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.no-workspace", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "code=\"kconfig.no-workspace\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.parse-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "code=\"kconfig.parse-failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.build-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.build-failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.unknown-subcommand", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.unknown-subcommand\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "presets.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/presets_cmd.py", + "literal": "\"presets.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "run.exec-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/run_cmd.py", + "literal": "\"run.exec-failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "run.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/run_cmd.py", + "literal": "\"run.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "run.manifest-stale", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/run_cmd.py", + "literal": "\"run.manifest-stale\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "run.native-sim-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/run_cmd.py", + "literal": "\"run.native-sim-unavailable\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.fetch-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "code=\"fetch-failed\"", + "note": "The `sdk.` prefix is applied by `_fail()`. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "code=\"internal-failure\"", + "note": "The `sdk.` prefix is applied by `_fail()`. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.network-required", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "Issue(\"sdk.network-required\", \"warning\", ...)", + "note": "tan-cli#351: fires when `sdk list` is run without `--online` -- now a `warning` on a SUCCESS envelope (exit 0, `ok: true`), not an `error` on a failure. This port gates the network call the oracle itself reaches unconditionally (the oracle has no `--online` flag at all), for hermeticity; that gate is a normal, everyday state -- not a verdict on anything the caller did wrong -- so it must not exit non-zero, matching `sdk current`'s exit-0 answer to the same shape of question (`sdk-current-no-sdk`). Was `severity: \"error\"`, exit 1, emitted through `_fail()`'s `f\"sdk.{code}\"` prefixing helper (`literal: code=\"network-required\"`) until #351; now a direct literal `Issue(...)` call, still covered by the same emit-site gate under the plain-literal scan instead of the prefixing one. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.not-ported", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "code=\"not-ported\"", + "note": "The `sdk.` prefix is applied by `_fail()`; `sdk install`/`sdk switch` refuse outright in this build (tan-cli#305). Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.project-pin-unresolved", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "\"sdk.project-pin-unresolved\"", + "note": "tan-cli#263: shared by every caller of `resolve_sdk_tiered` (not just `sdk current`) when `.alp/sdk-path` names a checkout that no longer resolves and the ladder fell through to another tier. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.unknown-subcommand", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "code=\"unknown-subcommand\"", + "note": "The `sdk.` prefix is applied by `_fail()`. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.budget-unknown", + "status": "reserved", + "severity": "info", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.budget-unknown\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.over-budget", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.over-budget\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "validate.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/validate_cmd.py", + "literal": "\"board-yaml-missing\"", + "note": "The `validate.` prefix is applied by the local `fail()` closure. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "validate.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/validate_cmd.py", + "literal": "\"internal-failure\"", + "note": "The `validate.` prefix is applied by the local `fail()` closure; two call sites share it -- an unreadable/non-UTF-8 board.yaml, and `validate_board_text` raising unexpectedly. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "validate.schema-violation", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/validate_cmd.py", + "literal": "\"schema-violation\"", + "note": "The `validate.` prefix is applied by the local `fail()` closure (the `BoardShapeError` path) and, separately, by `Issue(f\"validate.{result.outcome}\", ...)` -- `result.outcome` is only ever `OUTCOME_SCHEMA_VIOLATION` (\"schema-violation\") on that path, since a clean result carries no messages to iterate. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "validate.spawn-not-implemented", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/validate_cmd.py", + "literal": "\"spawn-not-implemented\"", + "note": "The `validate.` prefix is applied by the local `fail()` closure; the full (spawn) validator is not ported yet -- run with `--offline` (tan-cli#262). Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.artefact-write-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/materialise.py", + "literal": "\"build.artefact-write-failed\"", + "note": "Constructed as the whole literal at a `MaterialiseError` call site in `materialise.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.conflicting-flags", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.conflicting-flags\"", + "note": "Constructed as the whole literal at a `_refuse` call site in `build_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.materialise-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.materialise-failed\"", + "note": "Constructed as the whole literal at a `BuildError` call site in `build_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.path-escape", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/materialise.py", + "literal": "\"build.path-escape\"", + "note": "Constructed as the whole literal at a `MaterialiseError` call site in `materialise.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.plan-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/core/build_plan.py", + "literal": "\"build.plan-invalid\"", + "note": "Constructed as the whole literal at a `PlanParseError` call site in `build_plan.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.plan-token-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/token_substitution.py", + "literal": "\"build.plan-token-unresolved\"", + "note": "Constructed as the whole literal at a `TokenSubstitutionError` call site in `token_substitution.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.plan-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.plan-unavailable\"", + "note": "Constructed as the whole literal at a `BuildError` call site in `build_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.plan-unsupported-schema", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/core/build_plan.py", + "literal": "\"build.plan-unsupported-schema\"", + "note": "Constructed as the whole literal at a `PlanParseError` call site in `build_plan.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.project-root-mismatch", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/token_substitution.py", + "literal": "\"build.project-root-mismatch\"", + "note": "Constructed as the whole literal at a `TokenSubstitutionError` call site in `token_substitution.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.sdk-commit-mismatch", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/token_substitution.py", + "literal": "\"build.sdk-commit-mismatch\"", + "note": "Constructed as the whole literal at a `TokenSubstitutionError` call site in `token_substitution.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/token_substitution.py", + "literal": "\"build.sdk-root-unresolved\"", + "note": "Constructed as the whole literal at a `TokenSubstitutionError` call site in `token_substitution.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.ambiguous-selector", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.ambiguous-selector\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.internal-failure\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.positional-template-conflict", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.positional-template-conflict\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.target-unknown", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.target-unknown\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.template-unknown", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.template-unknown\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.template-unreadable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.template-unreadable\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.manifest-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.manifest-invalid\"", + "note": "Constructed as the whole literal at a `_error` call site in `flash_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.manifest-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.manifest-not-found\"", + "note": "Constructed as the whole literal at a `_error` call site in `flash_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.board-sku-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.board-sku-unresolved\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.board-yaml-missing\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.internal-failure\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.invalid-executor", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.invalid-executor\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.invalid-target", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.invalid-target\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.output-unwritable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.output-unwritable\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.python-too-old", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.python-too-old\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.sdk-root-unresolved\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.would-overwrite", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.would-overwrite\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.write-escapes-project", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.write-escapes-project\"", + "note": "Raised as a `GenerateError` with `ExitCode.WRITE_FAILURE` when `resolve_confined` finds a target's output path resolving outside the project root, refusing the whole run rather than any target. Added by tan-cli#325 (`fix(init,generate): confine writes to the project after symlink resolution`) and caught UNREGISTERED by the tan-cli#224 emit-site gate the first time the two met in one tree -- the gate's first catch on code it did not itself ship. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.manifest-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/image_cmd.py", + "literal": "\"image.manifest-invalid\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `image_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.manifest-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/image_cmd.py", + "literal": "\"image.manifest-unavailable\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `image_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.board-yaml-unreadable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.board-yaml-unreadable\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.board-yaml-unsupported", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.board-yaml-unsupported\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.example-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.example-not-found\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.example-unreadable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.example-unreadable\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.internal-failure\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-cores", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-cores\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-example", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-example\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-name", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-name\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-som", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-som\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-template", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-template\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.sdk-root-unresolved\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.template-unreadable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.template-unreadable\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.board-yaml-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "\"kconfig.board-yaml-invalid\"", + "note": "Constructed as the whole literal at a `_CoreResolutionError` call site in `kconfig_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "\"kconfig.board-yaml-missing\"", + "note": "Constructed as the whole literal at a `_CoreResolutionError` call site in `kconfig_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.core-ambiguous", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "\"kconfig.core-ambiguous\"", + "note": "Constructed as the whole literal at a `_CoreResolutionError` call site in `kconfig_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.board-yaml-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.board-yaml-invalid\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.board-yaml-missing\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.build-timeout", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.build-timeout\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.python-too-old", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.python-too-old\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.sdk-root-unresolved\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.launch-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.launch-failed\"", + "note": "Constructed as the whole literal at a `MonitorError` call site in `monitor_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.no-port", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.no-port\"", + "note": "Constructed as the whole literal at a `MonitorError` call site in `monitor_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.pyserial-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.pyserial-missing\"", + "note": "Constructed as the whole literal at a `MonitorError` call site in `monitor_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.argv-rejected", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.argv-rejected\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.binary-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.binary-missing\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.cpu-halted", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.cpu-halted\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.descriptor", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.descriptor\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.descriptor-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.descriptor-missing\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.elf-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.elf-missing\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.exited-nonzero", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.exited-nonzero\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.expect-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.expect-not-found\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.image-bundle-unused", + "status": "reserved", + "severity": "info", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.image-bundle-unused\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.manifest-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.manifest-invalid\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.manifest-schema", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.manifest-schema\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.manifest-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.manifest-unavailable\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.run-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.run-failed\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sdk-root-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.sdk-root-not-found\"", + "note": "Constructed as the whole literal at a `fail` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sku-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.sku-unresolved\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.slice", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.slice\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.manifest-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.manifest-invalid\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `size_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.manifest-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.manifest-unavailable\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `size_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.boardYaml", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"boardYaml\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.bootstrapManifest", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"bootstrapManifest\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.homePath", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"homePath\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.hostPrerequisites", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"hostPrerequisites\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.hostPython", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"hostPython\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.jlink", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"jlink\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.longPaths", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"longPaths\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.pythonFloor", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"pythonFloor\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.sdk", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"sdk\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.sdkProvenance", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"sdkProvenance\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.setools", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"setools\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.sevenZip", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"sevenZip\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.venvProvenance", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"venvProvenance\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.west", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"west\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.westResolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"westResolved\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.workspace", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"workspace\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrSdk", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrSdk\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrSdkAvailableForHost", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrSdkAvailableForHost\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrVersion", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrVersion\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrWorkspace", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrWorkspace\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "migrate.failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/west_forward_cmd.py", + "literal": ".failed", + "note": "The mirrored `f\"{subcommand}.failed\"` shape (family substituted, suffix fixed) at `python/tan/commands/west_forward_cmd.py`'s `_run_forward` -- the reverse of `bootstrap.`-style prefixing, and a second live call site of the exact defect tan-cli#224 reports (a code assembled by interpolation no literal scan can see) found while remediating that issue's own review. `subcommand` is closed to the three literal strings `tan migrate`'s own Typer command passes (`migrate`/`lock`/`quality`, `cli.py`). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "lock.failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/west_forward_cmd.py", + "literal": ".failed", + "note": "The mirrored `f\"{subcommand}.failed\"` shape (family substituted, suffix fixed) at `python/tan/commands/west_forward_cmd.py`'s `_run_forward` -- the reverse of `bootstrap.`-style prefixing, and a second live call site of the exact defect tan-cli#224 reports (a code assembled by interpolation no literal scan can see) found while remediating that issue's own review. `subcommand` is closed to the three literal strings `tan lock`'s own Typer command passes (`migrate`/`lock`/`quality`, `cli.py`). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "quality.failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/west_forward_cmd.py", + "literal": ".failed", + "note": "The mirrored `f\"{subcommand}.failed\"` shape (family substituted, suffix fixed) at `python/tan/commands/west_forward_cmd.py`'s `_run_forward` -- the reverse of `bootstrap.`-style prefixing, and a second live call site of the exact defect tan-cli#224 reports (a code assembled by interpolation no literal scan can see) found while remediating that issue's own review. `subcommand` is closed to the three literal strings `tan quality`'s own Typer command passes (`migrate`/`lock`/`quality`, `cli.py`). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "diff.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/diff_cmd.py", + "literal": "code=\"board-yaml-missing\"", + "note": "Assembled by `_emit_failure`'s `f\"diff.{code}\"` (prefix template) when diff's board.yaml path does not resolve or the file does not exist. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "diff.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/diff_cmd.py", + "literal": "code=\"internal-failure\"", + "note": "Assembled by `_emit_failure`'s `f\"diff.{code}\"` (prefix template): board.yaml could not be read (OSError/UnicodeDecodeError), or diff's outer backstop `except Exception` caught something unexpected. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "diff.pyyaml-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/diff_cmd.py", + "literal": "ParseFailure(\"pyyaml-unavailable\", ...), forwarded through _emit_failure(code=failure.code)", + "note": "_load_document refuses when PyYAML is not installed in this environment -- diff cannot even parse board.yaml without it. Reaches the wire as `diff.pyyaml-unavailable` via ParseFailure.code forwarded through _emit_failure's `f\"diff.{code}\"` prefix template. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "diff.schema-violation", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/diff_cmd.py", + "literal": "ParseFailure(\"schema-violation\", ...), forwarded through _emit_failure(code=failure.code)", + "note": "board.yaml is not valid YAML, or a field normalize_board_model needs is the wrong shape (a live false-refusal exists here for YAML-1.1-only bool spellings PyYAML's SafeLoader has already collapsed -- see the module docstring). Reaches the wire as `diff.schema-violation` via ParseFailure.code forwarded through _emit_failure's `f\"diff.{code}\"` prefix template. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "pinmux.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/pinmux_cmd.py", + "literal": "code=\"pinmux.internal-failure\" (or the equivalent literal Issue(...) construction)", + "note": "The catch-all exception handler in pinmux(): any unexpected failure resolving or reading the pinmux capability table. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "pinmux.family-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/pinmux_cmd.py", + "literal": "\"pinmux.family-invalid\"", + "note": "tan-cli#359: `--family` was joined onto the SDK root unvalidated, so an absolute value discarded the prefix (`Path(sdk) / \"/other/metadata/pinmux/aen\"` IS the other path) and `..` walked out of it -- the envelope still reported the sdkRoot it never read from. Emitted for BOTH halves of the fix: a `--family` that is not a plain stem (rejected before any IO), and a resolved table path that leaves `/metadata/pinmux` anyway (a symlink inside the SDK, which the stem check structurally cannot see). Exit code 2 (VALIDATION_FAILURE), and the target is never read. The Rust oracle has no equivalent check -- a deliberate divergence, not a port gap. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "trace.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/trace_cmd.py", + "literal": "code=\"sdk-root-unresolved\"", + "note": "Assembled by the nested fail() helper's `f\"trace.{code}\"` (prefix template) inside trace's Typer command: alp-sdk root is unresolved (no --sdk-root, no pin, no discoverable checkout). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "trace.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/trace_cmd.py", + "literal": "code=\"board-yaml-missing\"", + "note": "Assembled by the nested fail() helper's `f\"trace.{code}\"` (prefix template): board.yaml path could not be resolved or the file does not exist. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "trace.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/trace_cmd.py", + "literal": "code=\"internal-failure\"", + "note": "Assembled by the nested fail() helper's `f\"trace.{code}\"` (prefix template) when resolve_trace_targets raises TraceTargetError. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.boardYaml", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"boardYaml\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): the DEBUG-focused report's own `boardYaml` check (tan-cli#357), built by `_board_yaml_check` in support_bundle_cmd.py directly -- reports the resolved board.yaml PATH as its detail, unlike the build-checklist's `doctor.boardYaml`, which reports \"board.yaml found\" instead. `_doctor_section`, the function an earlier version of this note cited, was deleted by the #357 diff (tan-cli#374 finding 4). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.bootstrapManifest", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"bootstrapManifest\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): one of `_HOST_CHECK_ORDER`'s five names, harvested BY NAME from doctor_cmd._collect(...)'s own Check objects by `_host_checks_from_doctor` (tan-cli#357) -- mirrors `doctor.bootstrapManifest` verbatim. `_doctor_section`, the function an earlier version of this note cited, was deleted by the #357 diff (tan-cli#374 finding 4). tan-cli#374 finding 5: the oracle has no `bootstrapManifest` CHECK at all -- it folds a rejected metadata/bootstrap.json into `hostPrerequisites`'s own detail instead -- so this code is a documented port-only divergence with no oracle counterpart, always `warn` (never `fail`). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.gdb", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"gdb\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): the DEBUG-focused report's `yocto-userspace` tool check (tan-cli#357), built by `_target_checks` in support_bundle_cmd.py -- `warn` when no local gdb/arm-none-eabi-gdb is on PATH. tan-cli#374 finding 3: reachable and emitted on the wire before this code existed in the registry, missed because the gate's own declared value space (_FORWARDER_SUFFIXES) still described the pre-#357 build checklist. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.gdbserverBackend", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"gdbserverBackend\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): `_target_checks`'s `f\"{server}Backend\"` (tan-cli#357) for server == \"gdbserver\". NOT reachable through today's target/server pairing (debug_launch._SERVER_CHOICES only pairs gdbserver with yocto-userspace, which uses the separate `gdb` check instead), but `server` is a plain str parameter with nothing narrower enforcing that at this call site -- registered now (tan-cli#374 finding 3) so a future widening of that pairing table cannot put an unregistered code on the wire with nothing here to catch it. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.homePath", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"homePath\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): one of `_HOST_CHECK_ORDER`'s five names, harvested BY NAME from doctor_cmd._collect(...)'s own Check objects by `_host_checks_from_doctor` (tan-cli#357) -- mirrors `doctor.homePath` verbatim; see the sibling doctor.* entry for what this check verifies. `_doctor_section`, the function an earlier version of this note cited, was deleted by the #357 diff (tan-cli#374 finding 4). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.hostPrerequisites", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"hostPrerequisites\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): one of `_HOST_CHECK_ORDER`'s five names, harvested BY NAME from doctor_cmd._collect(...)'s own Check objects by `_host_checks_from_doctor` (tan-cli#357) -- mirrors `doctor.hostPrerequisites` verbatim; see the sibling doctor.* entry for what this check verifies. `_doctor_section`, the function an earlier version of this note cited, was deleted by the #357 diff (tan-cli#374 finding 4). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.jlinkBackend", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"jlinkBackend\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): `_target_checks`'s `f\"{server}Backend\"` (tan-cli#357) for server == \"jlink\", when --target-kind zephyr-mcu/baremetal-mcu --server jlink -- `warn` when no JLinkGDBServerCL/JLinkGDBServer is on PATH. tan-cli#374 finding 3: reachable and emitted on the wire before this code existed in the registry, missed because the gate's own declared value space (_FORWARDER_SUFFIXES) still described the pre-#357 build checklist. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.longPaths", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"longPaths\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): one of `_HOST_CHECK_ORDER`'s five names, harvested BY NAME from doctor_cmd._collect(...)'s own Check objects by `_host_checks_from_doctor` (tan-cli#357). tan-cli#306 widened doctor_cmd.long_paths_check with a Windows-only `fail` arm the oracle cannot reach at all (its own long_paths_check takes only the registry axis and never returns worse than warn); tan-cli#374 finding 1 found that arm reaching THIS command's verdict made a first-run customer host (registry LongPathsEnabled on, no global .gitconfig) exit 4 where the oracle exits 0. `support_bundle_cmd._demote_long_paths_fail` now caps it at `warn` before it is ever harvested here, so `error` severity is structurally unreachable from support-bundle (unlike from `tan doctor`, which keeps the real fail arm -- see the sibling doctor.longPaths entry). Severity corrected warning<-error here for that reason (tan-cli#374). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.noneBackend", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"noneBackend\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): `_target_checks`'s `f\"{server}Backend\"` (tan-cli#357) for server == \"none\". NOT reachable through today's target/server pairing (debug_launch._SERVER_CHOICES only pairs none with native-host, which uses the separate `lldb` check instead, always `pass`, #131), but `server` is a plain str parameter with nothing narrower enforcing that at this call site -- registered now (tan-cli#374 finding 3) so a future widening of that pairing table cannot put an unregistered code on the wire with nothing here to catch it. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.openocdBackend", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"openocdBackend\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): `_target_checks`'s `f\"{server}Backend\"` (tan-cli#357) for server == \"openocd\", when --target-kind zephyr-mcu/baremetal-mcu --server openocd -- `warn` when no openocd is on PATH. tan-cli#374 finding 3: reachable and emitted on the wire before this code existed in the registry, missed because the gate's own declared value space (_FORWARDER_SUFFIXES) still described the pre-#357 build checklist. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.pyocdBackend", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"pyocdBackend\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): `_target_checks`'s `f\"{server}Backend\"` (tan-cli#357) for server == \"pyocd\", when --target-kind zephyr-mcu/baremetal-mcu --server pyocd -- `warn` when no pyocd is on PATH. tan-cli#374 finding 3: reachable and emitted on the wire before this code existed in the registry, missed because the gate's own declared value space (_FORWARDER_SUFFIXES) still described the pre-#357 build checklist. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.sdkRoot", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"sdkRoot\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): the DEBUG-focused report's own `sdkRoot` check (tan-cli#357), built directly in `_debug_doctor_report` -- `fail` when no alp-sdk checkout resolved, mirroring the oracle's `status_pass_fail(has_sdk)`. tan-cli#374 finding 3: reachable and emitted at ERROR severity on the wire before this code existed in the registry -- the exact gap that finding names (measured: a fresh project with no resolvable SDK put this code on the wire with `pytest tests/gates/ -q` reporting all green). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "support-bundle.zephyrSdkAvailableForHost", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/support_bundle_cmd.py", + "literal": "f\"support-bundle.{c.name}\" where c.name == \"zephyrSdkAvailableForHost\"", + "note": "Assembled by _doctor_issues's `f\"support-bundle.{c.name}\"` (prefix template): one of `_HOST_CHECK_ORDER`'s five names, harvested BY NAME from doctor_cmd._collect(...)'s own Check objects by `_host_checks_from_doctor` (tan-cli#357) -- mirrors `doctor.zephyrSdkAvailableForHost` verbatim; see the sibling doctor.* entry for what this check verifies. `_doctor_section`, the function an earlier version of this note cited, was deleted by the #357 diff (tan-cli#374 finding 4). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.fix-needs-sudo", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "code=\"doctor.fix-needs-sudo\"", + "note": "ADR-0021's Tier-B refusal (tan-cli#91, MAINTAINER DECISION): --fix never spawns sudo on the customer's behalf -- fix_needs_sudo_check names the exact command and stops there rather than risking a password prompt with nowhere to go under --format json. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.fix-installed", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "code=\"doctor.fix-installed\"", + "note": "--fix ran a manifest install command (ADR-0021 Tier A) that needed no elevation and the child exited 0 -- fix_installed_check reports this, explicitly NOT a claim the tool is now on PATH within this same process (no same-process re-check is possible). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.fix-spawn-failed", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "code=\"doctor.fix-spawn-failed\"", + "note": "--fix resolved a tool's install command on PATH but starting it raised (OSError/ValueError/a non-timeout subprocess.SubprocessError) -- fix_spawn_failed_check reports this distinctly from silence, so a customer watching --fix do nothing can tell the OS refused to start it. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.fix-failed", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "code=\"doctor.fix-failed\"", + "note": "--fix ran a tool's install command and the child exited non-zero -- fix_failed_check is the only place a customer learns the install itself failed, rather than merely 'still missing'. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.fix-timed-out", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "code=\"doctor.fix-timed-out\"", + "note": "A tool's install command did not finish inside FIX_INSTALL_TIMEOUT_S (300s) and was killed -- fix_timed_out_check reports this so a hang does not read as up to 5 minutes of silent terminal (text mode only prints after the whole report completes). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.fix-installer-not-found", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "code=\"doctor.fix-installer-not-found\"", + "note": "tan-cli#360: --fix could not resolve the program a manifest install command starts with (`brew` on a Mac with no Homebrew, `winget` on a Windows image with no App Installer), so no repair ran for the tools that command covers -- fix_installer_not_found_check names the installer AND every tool it blocked, plus the remedy for that specific installer. Emitted ONCE PER INSTALLER, not per tool: one absent `brew` blocks every macOS prerequisite at once, and six identical 'install Homebrew' paragraphs would bury the report. Before this the path was a bare `continue`, so the least-equipped hosts -- exactly the audience --fix exists for -- got the same report back with no fix:* line at all. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.fix-suppressed", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "Issue(\"doctor.fix-suppressed\", ...)", + "note": "tan-cli#91 P1, measured against the oracle: `tan doctor --fix --format json` on an unhealthy host used to be a byte-for-byte silent no-op vs. plain `tan doctor`. fix_suppressed_issue reports HONESTLY instead: --fix was requested, the can_prompt consent gate refused it, and names which condition tripped (--format json, --ci, --non-interactive, or no interactive terminal). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "debug-config.gdbserver-address-unresolved", + "status": "reserved", + "severity": "info", + "consumer": "none -- no consumer matches this code yet; registered so a future one has a stable spelling to bind to rather than inventing its own", + "emittedBy": "python/tan/commands/debug_config_cmd.py", + "literal": "\"debug-config.gdbserver-address-unresolved\"", + "note": "tan-cli#321 direction 1, Python-only -- crates/ predates this feature and never emits it. Fires on a yocto-userspace `tan debug-config` run (both --preview and a write) whose final miDebuggerServerAddress is still the unresolved : placeholder: the host and gdbserver port are a runtime property of the deployed board that no build or SDK-published metadata can ever resolve. Checked against the FINAL configuration (the fresh draft on preview, the merged written configuration on write), so a customer who already hand-filled the real address is never re-nagged. Pre-consumer -- reserved for alp-sdk-vscode to surface this notice; nothing matches it yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "scaffold.name-required", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/scaffold_cmd.py", + "literal": "ScaffoldError(\"scaffold.name-required\", ...)", + "note": "--name is required and scaffold is not running interactively (or the interactive prompt was skipped) -- _need_name's ScaffoldError, re-emitted verbatim by _emit_error's `Issue(err.code, ...)`. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "scaffold.cancelled", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/scaffold_cmd.py", + "literal": "ScaffoldError(\"scaffold.cancelled\", ...)", + "note": "The interactive scaffold flow was cancelled -- _cancelled's ScaffoldError, re-emitted verbatim by _emit_error's `Issue(err.code, ...)`. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "scaffold.invalid-template", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/scaffold_cmd.py", + "literal": "ScaffoldError(\"scaffold.invalid-template\", ...)", + "note": "An unknown --template/template id was requested -- a ScaffoldError, re-emitted verbatim by _emit_error's `Issue(err.code, ...)`. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "scaffold.invalid-name", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/scaffold_cmd.py", + "literal": "ScaffoldError(\"scaffold.invalid-name\", ...)", + "note": "The given (or interactively entered) module name does not normalize to a valid identifier -- a ScaffoldError, re-emitted verbatim by _emit_error's `Issue(err.code, ...)`. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "scaffold.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/scaffold_cmd.py", + "literal": "ScaffoldError(\"scaffold.internal-failure\", ...)", + "note": "An unexpected failure during scaffold planning or writing -- a ScaffoldError, re-emitted verbatim by _emit_error's `Issue(err.code, ...)`. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sim-bundle-required", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "code=\"renode.sim-bundle-required\"", + "note": "--sim-mode requires --image-bundle . Faithful port of crates/tan-cli/src/commands/renode/sim.rs (tan-cli#77, 5152fd4); pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sim-bundle-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "code=\"renode.sim-bundle-missing\"", + "note": "The --image-bundle path is not a directory. Faithful port of crates/tan-cli/src/commands/renode/sim.rs (tan-cli#77, 5152fd4); pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sim-bind-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "code=\"renode.sim-bind-failed\"", + "note": "Could not bind the sim control/UART socket pair (OSError). Faithful port of crates/tan-cli/src/commands/renode/sim.rs (tan-cli#77, 5152fd4); pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sim-descriptor-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "code=\"renode.sim-descriptor-failed\"", + "note": "Could not write sim-descriptor.json to the image bundle. Faithful port of crates/tan-cli/src/commands/renode/sim.rs (tan-cli#77, 5152fd4); pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sim-boot-script-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "code=\"renode.sim-boot-script-failed\"", + "note": "Could not write the generated .sim-boot.resc boot script. Faithful port of crates/tan-cli/src/commands/renode/sim.rs (tan-cli#77, 5152fd4); pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sim-profile-deferred", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "Issue(\"renode.sim-profile-deferred\", \"warning\", ...)", + "note": "Every --sim-mode run carries this: the per-SKU sim profile (framebuffers/peripherals) is deferred (tan-cli#77 SCOPE), so the generated descriptor's arrays are always empty -- said explicitly so an empty descriptor is never mistaken for a completed feature. Faithful port of crates/tan-cli/src/commands/renode/sim.rs; pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.expect-ignored", + "status": "reserved", + "severity": "info", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "Issue(\"renode.expect-ignored\", \"info\", ...)", + "note": "--expect is accepted under --sim-mode (for global-flag parity with the plain smoke) but reported back as ignored rather than acted on: sim mode routes the console to the UART socket, not to a scannable text stream. Faithful port of crates/tan-cli/src/commands/renode/sim.rs (tan-cli#77); pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sim-monitor-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "code=\"renode.sim-monitor-failed\"", + "note": "The Renode monitor never became ready while draining boot output. Faithful port of crates/tan-cli/src/commands/renode/monitor.rs (tan-cli#77, 5152fd4), diff-verified live against the shipped oracle; pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sim-exited-early", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "code=\"renode.sim-exited-early\"", + "note": "Renode's process exited before the --timeout deadline while serving the sim sockets. Faithful port of crates/tan-cli/src/commands/renode/sim.rs (tan-cli#77), diff-verified live against the shipped oracle; pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + } + ] +} diff --git a/crates/tan-cli/src/commands/build/preflight.rs b/crates/tan-cli/src/commands/build/preflight.rs index 3b0a1dd3..20854c37 100644 --- a/crates/tan-cli/src/commands/build/preflight.rs +++ b/crates/tan-cli/src/commands/build/preflight.rs @@ -147,7 +147,7 @@ pub(super) fn maybe_auto_bootstrap( &BootstrapArgs { no_pip: false, no_west: false, - print_env: false, + print_env: false, allow_partial: false, workspace: None, }, diff --git a/crates/tan-cli/src/commands/debug_config.rs b/crates/tan-cli/src/commands/debug_config.rs index 625f0c2f..f6499794 100644 --- a/crates/tan-cli/src/commands/debug_config.rs +++ b/crates/tan-cli/src/commands/debug_config.rs @@ -1,2327 +1,2327 @@ -// SPDX-License-Identifier: Apache-2.0 -//! `tan debug-config` — generate (or preview) a VS Code launch.json entry. -//! -//! Mirrors TS `runDebugConfigCommand`: build a launch draft for the target/ -//! server, then either preview it (`--preview`) or merge it into -//! `/.vscode/launch.json`. Invalid kind / unsupported backend → -//! exit 5; a failed write → exit 3. - -use std::path::{Path, PathBuf}; - -use serde_json::Value; -use tan_core::run::{native_sim_exe_beside, native_sim_slice}; -use tan_core::runners::{parse_runners_config, runner_arg_value, runner_arg_values}; -use tan_core::size::{SocVariant, resolve_variant}; -use tan_core::system_manifest::{Slice, SystemManifest, parse_system_manifest}; -use tan_core::{ - DebugServerKind, DebugTargetKind, LaunchResolution, ProjectContext, apply_launch_resolution, - create_launch_draft, create_launch_json_write_plan, fill_debug_probe_identity_gaps, - is_unresolved_placeholder, launch_preview_document, launch_preview_notes, parse_board_model, - parse_server_kind, parse_target_kind, -}; - -use super::CommandRun; -use crate::cli::{DebugConfigArgs, GlobalArgs}; -use crate::envelope::{Envelope, Issue, Project}; -use crate::exit::ExitCode; -use crate::util::{generated_at_iso, normalize_path, resolve_cli_project_context_no_sdk_report}; - -/// `data` payload of the `debug-config` envelope (serialized as camelCase JSON). -#[derive(serde::Serialize)] -struct DebugConfigData { - /// Envelope data-schema version (currently `"1"`). - #[serde(rename = "schemaVersion")] - schema_version: String, - /// ISO-8601 generation timestamp. - #[serde(rename = "generatedAt")] - generated_at: String, - /// Resolved debug target kind. - #[serde(rename = "targetKind")] - target_kind: DebugTargetKind, - /// Resolved debug server backend. - server: DebugServerKind, - /// `true` when previewing only (no write performed). - preview: bool, - /// Path to the `.vscode/launch.json` that was (or would be) written. - #[serde(rename = "launchJsonPath")] - launch_json_path: String, - /// `true` when an existing launch config was replaced rather than appended. - replaced: bool, - /// Human-readable preview/usage notes. - notes: Vec, - /// The launch configuration itself — the very thing the command produces. - /// Additive: the envelope used to describe the write (path, replaced, - /// notes) without carrying the object, so an automated consumer had to - /// re-read `launch.json` or scrape the text preview to see what was - /// generated (alp-sdk-vscode#339). - configuration: Value, -} - -/// Entry point for `tan debug-config`: parse target/server, build the launch -/// draft, then preview it (`--preview`) or merge it into `.vscode/launch.json`. -pub fn run(g: &GlobalArgs, args: &DebugConfigArgs) -> CommandRun { - let generated_at = generated_at_iso(); - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - - // Errors before workspace resolution report a cwd-based launch.json path - // and a zephyr-mcu/none placeholder (matches the TS catch block). - let cwd_launch_path = || { - cwd.join(".vscode") - .join("launch.json") - .to_string_lossy() - .to_string() - }; - - let target = match parse_target_kind(args.target_kind.as_deref()) { - Ok(t) => t, - Err(message) => return internal_failure(g, &generated_at, message, cwd_launch_path()), - }; - let server = match parse_server_kind(args.server.as_deref()) { - Ok(s) => s, - Err(message) => return internal_failure(g, &generated_at, message, cwd_launch_path()), - }; - let mut draft = match create_launch_draft(target, server, args.pre_launch_task.as_deref()) { - Ok(d) => d, - Err(message) => return internal_failure(g, &generated_at, message, cwd_launch_path()), - }; - - let project_arg = g.project.clone().unwrap_or_else(|| ".".to_string()); - let workspace_root = normalize_path(&cwd.join(&project_arg)); - let launch_json_path = workspace_root - .join(".vscode") - .join("launch.json") - .to_string_lossy() - .to_string(); - - // tan-cli#170: every other command's `Project.root`/`Project.board_yaml` - // come from this SAME shared resolver (`bootstrap`, `doctor`, `presets`, - // `validate`, …); `debug-config` was the one holdout still hardcoding - // `board_yaml: None` on every path, even a success with a valid - // `board.yaml` sitting in the resolved root. Bound once (not just for - // `board_yaml_path`) so the reported `project.root` is this SAME - // `context.workspace_root` — already posix-normalized, like every other - // command's golden — instead of the locally-computed `workspace_root: - // PathBuf` below's native `to_string_lossy()`, which put a - // native-backslash `root` next to a forward-slash `boardYaml` in the same - // envelope object on Windows (#170 follow-up). Reporting-only — no - // consumer binds either field yet. The `_no_sdk_report` variant: unlike - // every other caller of this resolver, `debug-config` does not DRIVE the - // SDK the way `build`/`size`/`validate` do, so it must not add an - // undeclared `sdk` envelope key as a side effect of a field it merely - // reports (tan-cli#111 follow-up). alp-sdk#1026's metadata fallback below - // (`fill_debug_probe_identity_from_sdk`) does now best-effort READ under - // `context.sdk_root` when one resolves — that stays a silent, optional - // enrichment exactly like the `board/system-manifest.yaml` read already - // was, not a new reported dependency, so the choice not to record here - // is unchanged. - let context = resolve_cli_project_context_no_sdk_report(g); - // - // tan-cli#236 completes it: built through the shared constructor, so - // `boardYaml` is null when nothing is actually at the resolved path. - // Routing `board_yaml` through the resolver (above) without this would have - // traded a hardcoded null for a path to a file that need not exist — the - // same field disagreeing with the filesystem, in the other direction. - let project = Project::from_context(&context); - - // Fill the `` placeholders from what this project's own build - // recorded (#66). Nothing here fails the command: pre-build, or against a - // Zephyr that reshaped `runners.yaml`, the draft keeps its placeholders. - let (mut resolution, registered_runners, build_core_id) = - resolve_from_build(&workspace_root, target, server, args.core.as_deref()); - - // alp-sdk#1026: whatever the build did NOT already resolve, try the SDK's - // published per-variant debug-probe identity next — `--core` if given, - // else the core id the build itself just resolved. `targetId` (pyOCD) - // needs neither: `pyocd_target` is a scalar per variant, so it resolves - // pre-build with no `--core` and no prior build at all. `device` (J-Link) - // is the opposite: `jlink_device` is keyed BY core id, so on a - // never-built project with no `--core`, `identity_core` is `None` and - // `device` stays the placeholder — that combination is deliberately - // covered by a test (`fill_debug_probe_identity_gaps_never_guesses_a_device_without_a_matching_core_id` - // in `tan_core::debug_launch`, and `debug_config_jlink_device_stays_the_placeholder_with_no_core_and_no_build` - // here) rather than left silently unresolved with no coverage. - let identity_core = args.core.clone().or(build_core_id); - let before_identity_fill = resolution.clone(); - let identity_debug_block_found = - fill_debug_probe_identity_from_sdk(&mut resolution, &context, identity_core.as_deref()); - // Which launch-configuration JSON keys the SDK fallback (not a real - // build) just populated — the ONLY fields `sdk_identity_overwrites` below - // is allowed to flag (alp-sdk#1026 review finding #1). A field a real - // build already resolved is excluded here even though it may ALSO - // overwrite a customer's value: that overwrite is pre-existing, intended - // behaviour (`merge_configuration`'s own doc comment), not something this - // PR introduces or is scoped to disclose. - let mut sdk_filled_json_fields: Vec<&'static str> = Vec::new(); - if before_identity_fill.device.is_none() && resolution.device.is_some() { - sdk_filled_json_fields.push("device"); - } - if before_identity_fill.target_id.is_none() && resolution.target_id.is_some() { - sdk_filled_json_fields.push("targetId"); - } - if before_identity_fill.config_files.is_empty() && !resolution.config_files.is_empty() { - sdk_filled_json_fields.push("configFiles"); - } - - // `--svd` is the ONLY producer of `resolution.svd` (tan-cli#197): the SDK - // ships no SVD, so without the flag the field is structurally always - // `None` and `apply_launch_resolution` drops both svd keys. - if let Some(svd_arg) = args.svd.as_deref() { - match resolve_user_svd(&cwd, &workspace_root, svd_arg) { - Ok(svd) => resolution.svd = Some(svd), - Err(message) => { - return internal_failure(g, &generated_at, message, launch_json_path); - } - } - } - - apply_launch_resolution(&mut draft, &resolution); - let mut notes = preview_notes_for(&draft, ®istered_runners, server); - // A non-MCU draft carries no `svdFile` key at all, and - // `apply_launch_resolution` only replaces keys that already exist — so a - // `--svd` here is a no-op. Say so rather than accepting the flag in - // silence and leaving the user to wonder why no peripheral view appeared. - if args.svd.is_some() && draft.get("svdFile").is_none() { - notes.push(format!( - "--svd was given, but target kind '{}' emits no svdFile field, so it had no effect: \ - the Cortex Peripherals view is a cortex-debug (MCU) feature.", - args.target_kind.as_deref().unwrap_or("zephyr-mcu"), - )); - } - - // alp-sdk#1026 review finding #4: the generic "Placeholder fields..." - // note is real but unspecific — running `--server openocd` today gives - // `issues: []` / `ok: true` with the only signal being a note that names - // `device`, a key an OpenOCD draft does not even carry. When the SDK DID - // resolve an identity for this variant but not the specific field THIS - // server needs (every Alif variant today, for `openocd_config`), say so - // explicitly — on preview too, not just a write, since this is advisory - // about resolution state, not about what a write changed on disk. - let mut identity_issues: Vec = Vec::new(); - if identity_debug_block_found { - if let Some(field) = server_identity_field(server) { - if draft.get(field).map(has_placeholder).unwrap_or(false) { - identity_issues.push(sdk_identity_key_absent_issue(field)); - } - } - } - - if args.preview { - return success( - g, - &generated_at, - target, - server, - &launch_json_path, - true, - false, - ¬es, - &draft, - project, - identity_issues, - ); - } - - // Write mode: merge into .vscode/launch.json. - let vscode_dir = Path::new(&launch_json_path) - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| workspace_root.join(".vscode")); - if let Err(e) = std::fs::create_dir_all(&vscode_dir) { - return write_failure( - g, - &generated_at, - target, - server, - &launch_json_path, - e.to_string(), - ); - } - - // `.ok()` here used to collapse a READ error on an EXISTING launch.json - // (wrong encoding e.g. UTF-16LE from PowerShell `>` redirection, a denied - // ACL, a sharing violation) into the same `None` as "no file yet". That - // fed create_launch_json_write_plan(None, ...), which builds a *fresh* - // document and the write below then overwrote the user's file wholesale - // — silently destroying every hand-written debug configuration at exit 0. - // The malformed-JSON case just below is deliberately guarded (no write); - // a read error must refuse to write for the same reason. - let existing = if Path::new(&launch_json_path).exists() { - match std::fs::read_to_string(&launch_json_path) { - Ok(content) => Some(content), - Err(e) => { - return internal_failure( - g, - &generated_at, - format!("Alp: failed to read existing .vscode/launch.json: {e}"), - cwd_launch_path(), - ); - } - } - } else { - None - }; - - // alp-sdk#1026 review finding #1: compute this BEFORE the write, against - // the file as it stood — `create_launch_json_write_plan` below already - // performs the same overwrite (that part of its behaviour is intentional, - // see its own doc comment), this only detects it so it can be disclosed. - let sdk_identity_overwrites = - tan_core::sdk_identity_overwrites(existing.as_deref(), &draft, &sdk_filled_json_fields); - - let plan = match create_launch_json_write_plan(existing.as_deref(), &draft) { - Ok(p) => p, - // A malformed existing launch.json surfaces as an internal failure in TS. - Err(message) => return internal_failure(g, &generated_at, message, cwd_launch_path()), - }; - - if let Err(e) = std::fs::write(&launch_json_path, &plan.content) { - return write_failure( - g, - &generated_at, - target, - server, - &launch_json_path, - e.to_string(), - ); - } - - // #133 reopened: report a legacy-entry migration so the customer knows - // WHY the file changed under them (their old `"ALP: ..."` entry is gone, - // folded into the correctly-named one) rather than discovering it only by - // diffing the file themselves. - let mut issues = identity_issues; - if let Some(from) = &plan.migrated_from { - issues.push(legacy_entry_migrated_issue( - from, - draft["name"].as_str().unwrap_or_default(), - )); - } - // tan-cli#179: the ordinary same-name merge left a DIFFERENT leftover - // legacy entry silently untouched — say so, even though (unlike a - // migration) nothing about the file's shape changed because of it. - if let Some(legacy) = &plan.legacy_entry_present { - issues.push(legacy_entry_untouched_issue(legacy)); - } - // #182 review finding #2: a splice or fallback write that dropped a - // comment (or trailing comma) the customer's file held must say so — - // #182 named unqualified success on a write that destroys user-authored - // content as the one thing that is never acceptable, not just "diffable". - if plan.comments_dropped { - issues.push(comments_dropped_issue()); - } - // alp-sdk#1026 review finding #1: this write just replaced a concrete - // existing value with one resolved from the SDK's published debug-probe - // identity rather than a real build — say so, the same way a dropped - // comment is disclosed rather than left for the customer to notice by - // diffing the file themselves. - for (field, existing_value, incoming_value) in &sdk_identity_overwrites { - issues.push(sdk_identity_overwrite_issue( - field, - existing_value, - incoming_value, - )); - } - - success( - g, - &generated_at, - target, - server, - &launch_json_path, - false, - plan.replaced, - ¬es, - // tan-cli#180: report what this write actually put in the file — the - // merged/migrated result — never the fresh `draft`, which still - // carries its own `` placeholders even after a merge - // resolved them from the customer's real, hand-filled values. - &plan.written_configuration, - project, - issues, - ) -} - -/// The #133 migration report: emitted when a pre-#155 `"ALP: ..."` entry was -/// found in place of the current `"Alp: ..."` name and adopted onto it (see -/// `tan_core::debug_launch::create_launch_json_write_plan`). Severity `info`, -/// not `warning` or `error` — nothing failed and no action is required; this -/// exists so an automated consumer (or a customer reading `--format json`) -/// can tell WHY the file changed under them instead of only diffing it. -fn legacy_entry_migrated_issue(from: &str, to: &str) -> Issue { - Issue { - code: "debug-config.legacy-entry-migrated".to_string(), - severity: "info".to_string(), - message: format!( - "Migrated the legacy launch-configuration entry \"{from}\" into \"{to}\". \ - Any value you had hand-filled in on the old entry for an unresolved-\ - placeholder field (device, miDebuggerServerAddress, configFiles, …) \ - carried across; every other field tan owns was refreshed to this run's \ - values, same as an ordinary re-run. The old entry is gone." - ), - } -} - -/// tan-cli#179: emitted when the ORDINARY same-name merge ran (an exact hit -/// against the current `"Alp: ..."` name) and a legacy `"ALP: ..."` -/// counterpart of the SAME draft ALSO still sits in the file. Distinct from -/// [`legacy_entry_migrated_issue`], which fires on the MISS path where the -/// legacy entry is the one adopted — here NEITHER entry was touched beyond -/// the ordinary merge, so the customer's real hand-filled values may still be -/// stranded on the leftover entry with nothing pointing at it. Severity -/// `info`, same reasoning as the migration notice: nothing failed and there -/// is no forced action, but silence here is exactly the #133 symptom the -/// customer hits next. -fn legacy_entry_untouched_issue(legacy_name: &str) -> Issue { - Issue { - code: "debug-config.legacy-entry-untouched".to_string(), - severity: "info".to_string(), - message: format!( - "A leftover legacy launch-configuration entry \"{legacy_name}\" still sits in \ - .vscode/launch.json alongside the entry this run updated. It was left \ - untouched — nothing decides which of the two you may have hand-edited is \ - authoritative — so if you filled in real values on the legacy entry, copy \ - them onto the maintained one and remove the legacy entry yourself." - ), - } -} - -/// tan-cli#182 review finding #2: emitted whenever this write dropped a -/// comment (or trailing comma) sitting inside a byte span it rewrote — the -/// one maintained entry a splice replaced, or, on the whole-document -/// fallback, the customer's entire original file. Severity `info`, same as -/// [`legacy_entry_migrated_issue`]: nothing failed and there is no action to -/// take, but a tool that discarded user-authored content must never report -/// unqualified success (#182's own non-negotiable floor). -fn comments_dropped_issue() -> Issue { - Issue { - code: "debug-config.comments-dropped".to_string(), - severity: "info".to_string(), - message: "This write dropped a comment (or trailing comma) that sat inside the \ - part of .vscode/launch.json it rewrote — either inside the one entry \ - being updated, or, if the file's shape couldn't be confidently \ - spliced, anywhere in the file. Everything outside that span is \ - untouched." - .to_string(), - } -} - -/// alp-sdk#1026 review finding #1: emitted whenever the SDK's published -/// debug-probe identity (not a real build) just replaced a concrete existing -/// value on the entry this run wrote. Severity `info`, same reasoning as its -/// three siblings above: the overwrite itself is not new or wrong (a value -/// resolved from a real build already overwrote unconditionally, by design — -/// see `tan_core::debug_launch::merge_configuration`'s doc comment) but a -/// tool that replaces a customer's own value at `exit 0` with `issues: []` -/// has told them nothing happened. -fn sdk_identity_overwrite_issue(field: &str, existing_value: &str, incoming_value: &str) -> Issue { - Issue { - code: "debug-config.sdk-identity-overwrite".to_string(), - severity: "info".to_string(), - message: format!( - "This write replaced the existing `{field}` value \"{existing_value}\" with \ - \"{incoming_value}\", resolved from the SDK's published debug-probe identity \ - (alp-sdk#987) rather than from a real build. If \"{existing_value}\" was a value \ - you filled in on purpose — e.g. a J-Link flash-unlock device profile more specific \ - than the generic attach device the SDK publishes — restore it in \ - .vscode/launch.json; a value tan itself resolves from a real build will overwrite \ - it again the same way." - ), - } -} - -/// alp-sdk#1026 review finding #4: emitted when the SDK DID publish a -/// debug-probe identity for this project's SoC variant, but that identity -/// does not (yet) include a value for `field` — distinct from, and more -/// specific than, the generic "Placeholder fields..." note every unresolved -/// field already gets regardless of why. Severity `info`: this is the -/// schema's own documented stance (`soc-spec-v1.schema.json:379`) that an -/// unpopulated key is a published "unknown", not an error and not a bug. -fn sdk_identity_key_absent_issue(field: &str) -> Issue { - Issue { - code: "debug-config.sdk-identity-key-absent".to_string(), - severity: "info".to_string(), - message: format!( - "This SoM's SDK-published debug-probe identity (alp-sdk#987) does not include a \ - value for `{field}` yet, so it stays the placeholder shown in `configuration` — an \ - unpopulated key is the correct published \"unknown\" (alp-sdk#1026), never a guess." - ), - } -} - -/// Build a success `CommandRun`: emit the JSON envelope (or text lines) for a -/// completed preview or write at `ExitCode::Success`. -/// -/// `configuration` is the launch configuration to REPORT — the caller decides -/// which one that is. `--preview` never merges anything (it returns before -/// the customer's file is even read), so it passes the fresh draft, which is -/// also all there is. A write passes the write plan's own -/// `written_configuration` instead (tan-cli#180): the merged/migrated result -/// that actually landed on disk, not the draft's stale `` -/// placeholders a merge may have already overwritten with the customer's -/// real values. -#[allow(clippy::too_many_arguments)] -fn success( - g: &GlobalArgs, - generated_at: &str, - target: DebugTargetKind, - server: DebugServerKind, - launch_json_path: &str, - preview: bool, - replaced: bool, - notes: &[String], - configuration: &Value, - project: Project, - issues: Vec, -) -> CommandRun { - let data = DebugConfigData { - schema_version: "1".to_string(), - generated_at: generated_at.to_string(), - target_kind: target, - server, - preview, - launch_json_path: launch_json_path.to_string(), - replaced, - notes: notes.to_vec(), - configuration: configuration.clone(), - }; - let text = if g.is_json() { - Vec::new() - } else { - debug_config_text( - target, - server, - launch_json_path, - replaced, - preview, - notes, - configuration, - g, - &issues, - ) - }; - let json = g.is_json().then(|| { - Envelope::new( - "debug-config", - project, - data, - issues, - ExitCode::Success.code(), - ) - .to_json() - }); - CommandRun { - exit: ExitCode::Success, - text, - json, - } -} - -/// Failure `CommandRun` for invalid kind / unsupported backend / malformed -/// existing launch.json: exits `InternalFailure` (5) with a `zephyr-mcu`/`none` -/// placeholder target. -fn internal_failure( - g: &GlobalArgs, - generated_at: &str, - message: String, - launch_json_path: String, -) -> CommandRun { - failure_envelope( - g, - generated_at, - DebugTargetKind::ZephyrMcu, - DebugServerKind::None, - launch_json_path, - ExitCode::InternalFailure, - "internal-failure", - message, - vec!["debug-config: internal failure".to_string()], - ) -} - -/// Failure `CommandRun` for a filesystem error while creating the directory or -/// writing launch.json: exits `WriteFailure` (3), preserving the resolved -/// target/server. -fn write_failure( - g: &GlobalArgs, - generated_at: &str, - target: DebugTargetKind, - server: DebugServerKind, - launch_json_path: &str, - message: String, -) -> CommandRun { - failure_envelope( - g, - generated_at, - target, - server, - launch_json_path.to_string(), - ExitCode::WriteFailure, - "write-failure", - message, - vec!["debug-config: failed to write launch.json.".to_string()], - ) -} - -/// Shared failure path: assemble the issue + `data` payload, emit text or a -/// null-project JSON envelope, and return a `CommandRun` at the given `exit`. -#[allow(clippy::too_many_arguments)] -fn failure_envelope( - g: &GlobalArgs, - generated_at: &str, - target: DebugTargetKind, - server: DebugServerKind, - launch_json_path: String, - exit: ExitCode, - code: &str, - message: String, - mut text_lines: Vec, -) -> CommandRun { - let issues = vec![Issue { - code: format!("debug-config.{code}"), - severity: "error".to_string(), - message: message.clone(), - }]; - let data = DebugConfigData { - schema_version: "1".to_string(), - generated_at: generated_at.to_string(), - target_kind: target, - server, - preview: false, - launch_json_path, - replaced: false, - notes: Vec::new(), - // No draft exists on this path — the failure happened before (or - // instead of) generating one. `null`, not an empty object, so a - // consumer cannot mistake it for a configuration with no fields. - configuration: Value::Null, - }; - let text = if g.is_json() { - Vec::new() - } else { - text_lines.push(message); - text_lines - }; - // TS createFailureResult reports a null project. - let json = g.is_json().then(|| { - Envelope::new( - "debug-config", - Project { - root: None, - board_yaml: None, - }, - data, - issues, - exit.code(), - ) - .to_json() - }); - CommandRun { exit, text, json } -} - -/// Render the human-readable (non-JSON) output lines for a successful preview -/// or write, including the pretty-printed launch document and notes unless -/// `--quiet`. -#[allow(clippy::too_many_arguments)] -fn debug_config_text( - target: DebugTargetKind, - server: DebugServerKind, - launch_json_path: &str, - replaced: bool, - preview: bool, - notes: &[String], - draft: &Value, - g: &GlobalArgs, - issues: &[Issue], -) -> Vec { - let mut lines = Vec::new(); - if preview { - lines.push(format!( - "debug-config: preview target={} server={}", - target.as_str(), - server.as_str() - )); - lines.push(format!("launch.json path: {launch_json_path}")); - if !g.quiet { - lines.push(String::new()); - let document = launch_preview_document(draft.clone()); - lines.push(serde_json::to_string_pretty(&document).unwrap_or_default()); - lines.push(String::new()); - lines.extend(notes.iter().map(|n| format!("note: {n}"))); - } - } else { - let action = if replaced { "updated" } else { "written" }; - lines.push(format!( - "debug-config: {action} target={} server={}", - target.as_str(), - server.as_str() - )); - lines.push(format!("launch.json: {launch_json_path}")); - // Always shown, even under --quiet: this is a one-time notice that the - // file just lost a differently-named entry (folded into this one), not - // routine noise like the resolution notes below it. - for issue in issues { - if issue.code == "debug-config.legacy-entry-migrated" { - lines.push(format!("debug-config: {}", issue.message)); - } - } - // tan-cli#179: same treatment — a leftover legacy entry sitting - // untouched next to the one this run just updated is exactly the - // kind of fact that must survive --quiet, not routine resolution - // noise. - for issue in issues { - if issue.code == "debug-config.legacy-entry-untouched" { - lines.push(format!("debug-config: {}", issue.message)); - } - } - // Same treatment for a dropped comment/trailing comma (#182 review - // finding #2): a notice about content this run destroyed is never - // routine noise, so it survives --quiet too. - for issue in issues { - if issue.code == "debug-config.comments-dropped" { - lines.push(format!("note: {}", issue.message)); - } - } - if !g.quiet { - lines.extend(notes.iter().map(|n| format!("note: {n}"))); - } - } - lines -} - -/// The manifest `os` a debug target class runs on, or `None` for a target with -/// no per-core build slice keyed by `os`. `NativeHost` is exactly that case — -/// its slice is selected by board target instead, in [`select_slice`]. -fn manifest_os_for_target(target: DebugTargetKind) -> Option<&'static str> { - match target { - DebugTargetKind::ZephyrMcu => Some("zephyr"), - DebugTargetKind::BaremetalMcu => Some("baremetal"), - DebugTargetKind::YoctoUserspace => Some("yocto"), - DebugTargetKind::NativeHost => None, - } -} - -/// Select the manifest slice a debug draft resolves against, for a given -/// target/`--core`. -/// -/// `NativeHost` is a special case: its runnable artefact is the project's -/// `native_sim` slice, found by board target via -/// [`tan_core::run::native_sim_slice`] — the SAME discriminator `tan run` -/// uses to pick the host binary — not by `os`. A board that also builds a -/// real Zephyr MCU slice still has one or more slices with `os: zephyr`; the -/// old `os`-keyed match took the first of those, which on such a board is -/// often the MCU slice, pointing `Alp: Native Sim Debug` at a Cortex-M ELF -/// that CodeLLDB then can't launch on the host. `--core` is intentionally -/// unused on this arm: a `native_sim` slice's `core_id` is not a hardware -/// core selector. -/// -/// Every other target kind keeps the existing `os` + `--core` match. -fn select_slice<'a>( - manifest: &'a SystemManifest, - target: DebugTargetKind, - core: Option<&str>, -) -> Option<&'a Slice> { - if target == DebugTargetKind::NativeHost { - return native_sim_slice(manifest); - } - let os = manifest_os_for_target(target)?; - // `--core` names the slice outright; otherwise the first slice of this - // target's OS wins, which is the whole manifest on a single-core project. - manifest - .slices - .iter() - .find(|s| s.os == os && core.map(|c| s.core_id == c).unwrap_or(true)) -} - -/// The `runners.yaml` runner id a debug server reads its arguments from. -fn runner_id_for_server(server: DebugServerKind) -> Option<&'static str> { - match server { - DebugServerKind::Jlink => Some("jlink"), - DebugServerKind::Openocd => Some("openocd"), - DebugServerKind::Pyocd => Some("pyocd"), - DebugServerKind::Gdbserver | DebugServerKind::None => None, - } -} - -/// The launch-configuration JSON key the SDK's debug-probe identity -/// (`variants[].debug`) resolves for a given server — `None` for a server the -/// identity has no concept of at all (`gdbserver`/`none`, neither of which -/// `create_launch_draft` ever pairs with a `variants[].debug` field). -fn server_identity_field(server: DebugServerKind) -> Option<&'static str> { - match server { - DebugServerKind::Jlink => Some("device"), - DebugServerKind::Openocd => Some("configFiles"), - DebugServerKind::Pyocd => Some("targetId"), - DebugServerKind::Gdbserver | DebugServerKind::None => None, - } -} - -/// Rewrite a path under `workspace_root` as `${workspaceFolder}/`, so a -/// committed `launch.json` stays portable; an artefact outside the project -/// (an out-of-tree build root) is left absolute rather than mangled. -fn workspace_relative(workspace_root: &Path, path: &str) -> String { - Path::new(path) - .strip_prefix(workspace_root) - .ok() - .map(|rel| format!("${{workspaceFolder}}/{}", rel.to_string_lossy())) - .unwrap_or_else(|| path.to_string()) -} - -/// Resolve `--svd` into the value the launch configuration should carry. -/// -/// **Anchor: the current directory, not the project root.** `--svd` is a -/// per-invocation flag typed at a shell prompt, so a relative path means what -/// the shell means by it. (A board-level `debug.svd` key, should one ever be -/// added, travels with the project and must anchor on the project root -/// instead — the two have different lifetimes, so they get different anchors -/// deliberately rather than by omission.) The emitted string then goes through -/// the same [`workspace_relative`] rewrite as `executable`: inside the project -/// it becomes `${workspaceFolder}/…` so a committed launch.json stays -/// portable, outside it stays absolute — which is the normal case here, since -/// a vendor SVD lives in the vendor SDK the user installed. -/// -/// **A bad path is a HARD ERROR, never a silent drop back to "no SVD".** -/// tan-cli#67 established that cortex-debug fails the whole session on an -/// `svdFile` it cannot read, which is why the *unresolved* case drops the key. -/// But the user explicitly named this file: falling back would make a typo -/// indistinguishable from not passing the flag, and the failure would surface -/// as an unexplained empty peripheral view. Fail here, where the message can -/// name the path. -fn resolve_user_svd(cwd: &Path, workspace_root: &Path, arg: &str) -> Result { - if arg.trim().is_empty() { - return Err("Alp: --svd was given an empty path.".to_string()); - } - // `join` on an absolute `arg` replaces the base, so this handles both. - let candidate = normalize_path(&cwd.join(arg)); - let meta = std::fs::metadata(&candidate).map_err(|e| { - format!( - "Alp: --svd path cannot be read: {} ({e}). \ - Pass the path to the vendor's own .svd file; the SDK ships none (alp-sdk#948).", - candidate.display(), - ) - })?; - if !meta.is_file() { - return Err(format!( - "Alp: --svd path is not a file: {}", - candidate.display(), - )); - } - Ok(workspace_relative( - workspace_root, - &candidate.to_string_lossy(), - )) -} - -/// Everything this project's own build knows about how to debug it: the -/// per-core ELF from `system-manifest.yaml`, and the probe/tool paths from that -/// slice's `runners.yaml` — the same file `west flash` reads. -/// -/// Best-effort throughout. A missing manifest (pre-build), a missing slice, an -/// unreadable or reshaped `runners.yaml` each leave the corresponding field -/// unresolved instead of failing the command: `debug-config` must still emit -/// its draft before the first build. -/// -/// The third return value is the `core_id` of the slice this run actually -/// selected (`None` before a matching slice is found) — the SAME id `--core` -/// would have named explicitly. alp-sdk#1026's SDK-metadata fallback (see -/// `fill_debug_probe_identity_from_sdk`) needs it to index `jlink_device` -/// (keyed per core) even when the caller passed no `--core` of its own, so a -/// single-core project's ALREADY-built slice still resolves without forcing -/// the user to repeat a core id `tan` already knows. -fn resolve_from_build( - workspace_root: &Path, - target: DebugTargetKind, - server: DebugServerKind, - core: Option<&str>, -) -> (LaunchResolution, Vec, Option) { - let mut resolution = LaunchResolution::default(); - let manifest_path = workspace_root.join("build").join("system-manifest.yaml"); - let Ok(yaml) = std::fs::read_to_string(&manifest_path) else { - return (resolution, Vec::new(), None); - }; - let Ok(manifest) = parse_system_manifest(&yaml) else { - return (resolution, Vec::new(), None); - }; - let Some(slice) = select_slice(&manifest, target, core) else { - return (resolution, Vec::new(), None); - }; - let core_id = Some(slice.core_id.clone()); - - if let Some(artefact) = slice.output_artefact.as_deref().filter(|a| !a.is_empty()) { - // A manifest records the ELF for EVERY zephyr slice, native_sim - // included: `resolve_zephyr_artefact` (build/execute/manifest.rs) is - // tan's only writer of `output_artefact` and stores - // `/build/zephyr/zephyr.elf` unconditionally — there is no - // `.exe` branch, and alp-sdk (planner-only) never writes the field at - // all. - // So a host target needs the sibling swap, via the same tan-core - // helper `tan run` uses; every other target kind genuinely wants the - // artefact verbatim. #83 took it verbatim here too, which pointed - // `Alp: Native Sim Debug` at a `zephyr.elf` CodeLLDB cannot launch. - let artefact = match target { - DebugTargetKind::NativeHost => native_sim_exe_beside(artefact), - _ => artefact.to_string(), - }; - resolution.executable = Some(workspace_relative(workspace_root, &artefact)); - } - - let Some(build_dir) = slice.build_dir.as_deref().filter(|b| !b.is_empty()) else { - return (resolution, Vec::new(), core_id); - }; - let runners_path = Path::new(build_dir).join("zephyr").join("runners.yaml"); - let Ok(text) = std::fs::read_to_string(&runners_path) else { - return (resolution, Vec::new(), core_id); - }; - let Ok(runners) = parse_runners_config(&text) else { - return (resolution, Vec::new(), core_id); - }; - - resolution.gdb_path = runners.gdb.clone(); - if let Some(runner) = runner_id_for_server(server) { - match server { - DebugServerKind::Jlink => { - resolution.device = runner_arg_value(&runners, runner, "--device"); - } - DebugServerKind::Openocd => { - resolution.server_path = runners.openocd.clone(); - resolution.search_dirs = runners.openocd_search.clone(); - resolution.config_files = runner_arg_values(&runners, runner, "--config"); - } - DebugServerKind::Pyocd => { - resolution.target_id = runner_arg_value(&runners, runner, "--target"); - } - DebugServerKind::Gdbserver | DebugServerKind::None => {} - } - } - (resolution, runners.runners.clone(), core_id) -} - -/// alp-sdk#1026: fill `resolution`'s remaining `device`/`target_id`/ -/// `config_files` gaps from the SDK's published per-variant debug-probe -/// identity (`variants[].debug`, alp-sdk#987), so `tan debug-config` resolves -/// a real J-Link device / pyOCD target before the project has ever been -/// built — the case `resolve_from_build`'s `runners.yaml` read structurally -/// cannot cover. -/// -/// Reuses the SAME metadata-layout walk `tan size` drives -/// (`crate::util::read_sdk_som_and_soc`) instead of a second walk of -/// `metadata/socs/**` — the exact drift #1026 itself is about (a schema with -/// no reader, then two readers that could disagree). Pure fill-the-gap logic -/// is `fill_debug_probe_identity_gaps` (`tan_core::debug_launch`); everything -/// here is the IO side: locating `board.yaml`, reading `som.sku` out of it, -/// then the shared SoM-preset/SoC-JSON read and (unlike `tan size`) a -/// forward-only `resolve_variant` match. -/// -/// Best-effort throughout, exactly like `resolve_from_build`: a missing -/// `board.yaml`/`som.sku`, no resolved SDK root, a missing/unreadable SoM -/// preset or SoC-JSON file, or a SoC variant that resolves but declares no -/// `debug` block each leave `resolution` exactly as it was — the caller's -/// existing placeholder note still applies, and nothing here can fail the -/// command. -/// -/// Returns whether a `variants[].debug` block was actually found for the -/// resolved SoC variant — distinct from whether every field this run wanted -/// got filled from it. `run` uses this (alp-sdk#1026 review finding #4) to -/// tell "the SDK publishes an identity for this part, but not a value for -/// the specific field this server needs yet" (e.g. every Alif variant today, -/// for `openocd_config`) apart from "no identity was resolvable at all" — -/// only the former is worth a dedicated notice; the latter is already the -/// generic "still needs resolution" note every unresolved field gets. -fn fill_debug_probe_identity_from_sdk( - resolution: &mut LaunchResolution, - context: &ProjectContext, - core_id: Option<&str>, -) -> bool { - let Some(board_yaml_path) = context.board_yaml_path.as_deref() else { - return false; - }; - let Ok(board_text) = std::fs::read_to_string(board_yaml_path) else { - return false; - }; - let Ok(model) = parse_board_model(&board_text) else { - return false; - }; - let Some(sku) = model.som.and_then(|som| som.sku) else { - return false; - }; - let Some(sdk_root) = context.sdk_root.as_deref() else { - return false; - }; - let metadata_root = Path::new(sdk_root).join("metadata"); - - // Shared metadata-layout walk with `tan size` — see `read_sdk_som_and_soc`'s - // doc comment. `sku: None` here (unlike `tan size`) deliberately disables - // `resolve_variant`'s sku reverse-fallback: a drifted/`TBD` preset must - // resolve NO identity rather than possibly a WRONG one that still - // connects a live debug session to the wrong part (alp-sdk#1026 review - // finding #7) — a missing budget is a lesser harm than a wrong device. - let Some((preset, soc)) = crate::util::read_sdk_som_and_soc(&metadata_root, &sku) else { - return false; - }; - let variants: Vec = soc - .get("variants") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - let Some(variant) = resolve_variant(preset.silicon_variant.as_deref(), None, &variants) else { - return false; - }; - let Some(debug) = variant.debug.as_ref() else { - return false; - }; - fill_debug_probe_identity_gaps( - resolution, - core_id, - &debug.jlink_device, - debug.pyocd_target.as_deref(), - debug.openocd_config.as_deref(), - ); - true -} - -/// Whether any `<…>` placeholder survived resolution, anywhere in the draft — -/// including inside `configFiles`, which is an array. -/// -/// The string test is [`is_unresolved_placeholder`], the SAME predicate the -/// launch.json merge uses, so "keep the still-needs-resolution note" and "do -/// not overwrite this by hand-filled value" can never disagree. It used to be -/// `s.contains(":` a -/// real address: a yocto config whose `` resolved then dropped -/// the note while `miDebuggerServerAddress` was still unusable. -fn has_placeholder(value: &Value) -> bool { - match value { - Value::String(s) => is_unresolved_placeholder(s), - Value::Array(items) => items.iter().any(has_placeholder), - Value::Object(map) => map.values().any(has_placeholder), - _ => false, - } -} - -/// The preview notes, minus the "still needs resolution" warning once nothing -/// is left to resolve. Keyed off the FINAL draft rather than off "did anything -/// resolve": a partly-resolved config (a board that registers no OpenOCD runner -/// still has ``) must keep the warning, and a fully -/// resolved one must lose it — otherwise the note is noise on configs that are -/// fine and silence on configs that are not. -fn preview_notes_for( - draft: &Value, - registered_runners: &[String], - server: DebugServerKind, -) -> Vec { - let mut notes: Vec = launch_preview_notes() - .into_iter() - .filter(|n| !n.starts_with("Placeholder fields") || has_placeholder(draft)) - .collect(); - // The most common reason a placeholder survives: the board never registered - // this server. Say so, instead of leaving the user to wonder which project- - // specific value they are supposed to invent. - if let Some(runner) = runner_id_for_server(server) { - if !registered_runners.is_empty() && !registered_runners.iter().any(|r| r == runner) { - notes.push(format!( - "This build registers no '{runner}' runner (runners.yaml: {registered_runners:?}), \ - so its fields could not be resolved.", - )); - } - } - notes -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cli::Format; - - fn tmp(tag: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("tan-debug-config-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - fn global(project: &Path) -> GlobalArgs { - GlobalArgs { - project: Some(project.to_string_lossy().into_owned()), - board_yaml: None, - sdk_root: None, - target: None, - all: false, - format: Format::Text, - verbose: false, - quiet: true, - no_color: true, - non_interactive: true, - ci: false, - } - } - - // Regression for the data-loss bug: a read error on an EXISTING - // launch.json (here, non-UTF-8 bytes as PowerShell `>` redirection would - // produce) must refuse to write, exactly like the malformed-JSON case. - // Before the fix, `.ok()` turned the read Err into `None`, which was - // treated as "no file yet" and the write below overwrote it wholesale. - #[test] - fn unreadable_existing_launch_json_refuses_to_write() { - let dir = tmp("unreadable"); - let vscode_dir = dir.join(".vscode"); - std::fs::create_dir_all(&vscode_dir).unwrap(); - let launch_json = vscode_dir.join("launch.json"); - let not_utf8: &[u8] = &[0xFF, 0xFE, b'{', 0, b'}', 0]; - std::fs::write(&launch_json, not_utf8).unwrap(); - - let g = global(&dir); - let args = DebugConfigArgs { - core: None, - target_kind: Some("zephyr-mcu".to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: None, - preview: false, - }; - let run_result = run(&g, &args); - - assert_eq!(run_result.exit, ExitCode::InternalFailure); - let after = std::fs::read(&launch_json).unwrap(); - assert_eq!( - after, not_utf8, - "an unreadable existing launch.json must be left untouched, not overwritten" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// alp-sdk#1026 end-to-end: with NO build at all (no `system-manifest.yaml`, - /// no `runners.yaml`), a `board.yaml` naming a SoM and an SDK checkout - /// publishing that SoM's variant `debug` block, `device`/`targetId` must - /// resolve from the SDK metadata rather than staying the placeholder -- - /// the exact gap #1026 reports as inert. - #[test] - fn debug_config_resolves_device_and_target_id_from_sdk_metadata_pre_build() { - let dir = tmp("sdk-metadata-fallback"); - std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); - - let sdk = dir.join("sdk"); - std::fs::create_dir_all(sdk.join("scripts")).unwrap(); - std::fs::write(sdk.join("scripts").join("alp_project.py"), "").unwrap(); - let som_dir = sdk.join("metadata").join("e1m_modules"); - std::fs::create_dir_all(&som_dir).unwrap(); - std::fs::write( - som_dir.join("E1M-AEN801.yaml"), - "schema_version: 1\nsku: E1M-AEN801\nsilicon: alif:ensemble:e8\n\ - silicon_variant: AE822FA0E5597LS0\n", - ) - .unwrap(); - let soc_dir = sdk - .join("metadata") - .join("socs") - .join("alif") - .join("ensemble"); - std::fs::create_dir_all(&soc_dir).unwrap(); - std::fs::write( - soc_dir.join("e8.json"), - r#"{ - "soc_spec_version": 1, - "ref": "alif:ensemble:e8", - "vendor": "Alif Semiconductor", - "family": "Ensemble", - "part": "E8", - "variants": [ - { - "order_code": "AE822FA0E5597LS0", - "debug": { - "pyocd_target": "AE822FA0E5597LS0", - "jlink_device": {"m55_hp": "Cortex-M55", "m55_he": "Cortex-M55"} - } - } - ] - }"#, - ) - .unwrap(); - - let mut g = global(&dir); - g.sdk_root = Some(sdk.to_string_lossy().into_owned()); - g.format = Format::Json; - let args = DebugConfigArgs { - core: Some("m55_hp".to_string()), - target_kind: Some("zephyr-mcu".to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: None, - preview: true, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - let json: serde_json::Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - assert_eq!(json["data"]["configuration"]["device"], "Cortex-M55"); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// The `--server pyocd` sibling of the test above: `targetId` resolves - /// from `pyocd_target`, and needs no `--core` at all (`jlink_device` is - /// the only per-core field; `pyocd_target` is a scalar per variant). - #[test] - fn debug_config_resolves_pyocd_target_id_from_sdk_metadata_pre_build() { - let dir = tmp("sdk-metadata-fallback-pyocd"); - std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); - - let sdk = dir.join("sdk"); - std::fs::create_dir_all(sdk.join("scripts")).unwrap(); - std::fs::write(sdk.join("scripts").join("alp_project.py"), "").unwrap(); - let som_dir = sdk.join("metadata").join("e1m_modules"); - std::fs::create_dir_all(&som_dir).unwrap(); - std::fs::write( - som_dir.join("E1M-AEN801.yaml"), - "schema_version: 1\nsku: E1M-AEN801\nsilicon: alif:ensemble:e8\n\ - silicon_variant: AE822FA0E5597LS0\n", - ) - .unwrap(); - let soc_dir = sdk - .join("metadata") - .join("socs") - .join("alif") - .join("ensemble"); - std::fs::create_dir_all(&soc_dir).unwrap(); - std::fs::write( - soc_dir.join("e8.json"), - r#"{ - "soc_spec_version": 1, - "ref": "alif:ensemble:e8", - "vendor": "Alif Semiconductor", - "family": "Ensemble", - "part": "E8", - "variants": [ - { - "order_code": "AE822FA0E5597LS0", - "debug": { - "pyocd_target": "AE822FA0E5597LS0", - "jlink_device": {"m55_hp": "Cortex-M55", "m55_he": "Cortex-M55"} - } - } - ] - }"#, - ) - .unwrap(); - - let mut g = global(&dir); - g.sdk_root = Some(sdk.to_string_lossy().into_owned()); - g.format = Format::Json; - let args = DebugConfigArgs { - core: None, - target_kind: Some("zephyr-mcu".to_string()), - server: Some("pyocd".to_string()), - pre_launch_task: None, - svd: None, - preview: true, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - let json: serde_json::Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - assert_eq!( - json["data"]["configuration"]["targetId"], - "AE822FA0E5597LS0" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// alp-sdk#987's own stance: `openocd_config` is absent from every SoC - /// family today, and that absence must stay the published "unknown" -- - /// the OpenOCD draft's `configFiles` keeps its placeholder rather than - /// inventing a config path, and the preview note says so. - #[test] - fn debug_config_openocd_config_files_stays_the_placeholder_when_the_sdk_publishes_none() { - let dir = tmp("sdk-metadata-fallback-openocd"); - std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); - - let sdk = dir.join("sdk"); - std::fs::create_dir_all(sdk.join("scripts")).unwrap(); - std::fs::write(sdk.join("scripts").join("alp_project.py"), "").unwrap(); - let som_dir = sdk.join("metadata").join("e1m_modules"); - std::fs::create_dir_all(&som_dir).unwrap(); - std::fs::write( - som_dir.join("E1M-AEN801.yaml"), - "schema_version: 1\nsku: E1M-AEN801\nsilicon: alif:ensemble:e8\n\ - silicon_variant: AE822FA0E5597LS0\n", - ) - .unwrap(); - let soc_dir = sdk - .join("metadata") - .join("socs") - .join("alif") - .join("ensemble"); - std::fs::create_dir_all(&soc_dir).unwrap(); - // No openocd_config key -- exactly every real Alif variant today. - std::fs::write( - soc_dir.join("e8.json"), - r#"{ - "soc_spec_version": 1, - "ref": "alif:ensemble:e8", - "vendor": "Alif Semiconductor", - "family": "Ensemble", - "part": "E8", - "variants": [ - { - "order_code": "AE822FA0E5597LS0", - "debug": { - "pyocd_target": "AE822FA0E5597LS0", - "jlink_device": {"m55_hp": "Cortex-M55"} - } - } - ] - }"#, - ) - .unwrap(); - - let mut g = global(&dir); - g.sdk_root = Some(sdk.to_string_lossy().into_owned()); - g.format = Format::Json; - let args = DebugConfigArgs { - core: Some("m55_hp".to_string()), - target_kind: Some("zephyr-mcu".to_string()), - server: Some("openocd".to_string()), - pre_launch_task: None, - svd: None, - preview: true, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - let json: serde_json::Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - assert_eq!( - json["data"]["configuration"]["configFiles"], - serde_json::json!([""]), - "an absent openocd_config must stay the placeholder, never a guess" - ); - assert!( - json["data"]["notes"].as_array().unwrap().iter().any(|n| n - .as_str() - .unwrap_or_default() - .starts_with("Placeholder fields")), - "the placeholder note must still be present: {}", - json["data"]["notes"] - ); - // alp-sdk#1026 review finding #4: the generic note above names - // `device`, which this OpenOCD draft does not even carry -- the - // specific, correctly-worded signal is this issue, present even on - // `--preview` since it is advisory about resolution state, not about - // a write. - let issues = json["issues"].as_array().expect("issues array"); - assert!( - issues - .iter() - .any(|i| i["code"] == "debug-config.sdk-identity-key-absent" - && i["message"] - .as_str() - .unwrap_or_default() - .contains("configFiles")), - "expected a sdk-identity-key-absent issue naming configFiles: {issues:?}" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// alp-sdk#1026 review finding #1 (data loss): a WRITE, not a preview — - /// every one of the three tests above only ever exercised `--preview`, - /// so the write path with this fallback had zero coverage. A customer's - /// `.vscode/launch.json` already holds a concrete, hand-filled `device` - /// (here, the more-specific `jlink_flash_device`-style profile a - /// customer might reasonably have copied in); the SDK's generic - /// `jlink_device` identity resolves and REPLACES it, same as a real - /// build's resolution always has — but this run must disclose that in - /// `issues[]`, not report `ok: true` / `issues: []` as if nothing - /// happened. - #[test] - fn debug_config_write_discloses_when_sdk_identity_overwrites_a_hand_filled_device() { - let dir = tmp("sdk-metadata-overwrite-disclosure"); - std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); - - let vscode_dir = dir.join(".vscode"); - std::fs::create_dir_all(&vscode_dir).unwrap(); - std::fs::write( - vscode_dir.join("launch.json"), - r#"{ - "version": "0.2.0", - "configurations": [ - { - "name": "Alp: Zephyr Debug (J-Link)", - "type": "cortex-debug", - "request": "launch", - "servertype": "jlink", - "device": "AE822FA0E5597LS0_M55_HE" - } - ] - }"#, - ) - .unwrap(); - - let sdk = dir.join("sdk"); - std::fs::create_dir_all(sdk.join("scripts")).unwrap(); - std::fs::write(sdk.join("scripts").join("alp_project.py"), "").unwrap(); - let som_dir = sdk.join("metadata").join("e1m_modules"); - std::fs::create_dir_all(&som_dir).unwrap(); - std::fs::write( - som_dir.join("E1M-AEN801.yaml"), - "schema_version: 1\nsku: E1M-AEN801\nsilicon: alif:ensemble:e8\n\ - silicon_variant: AE822FA0E5597LS0\n", - ) - .unwrap(); - let soc_dir = sdk - .join("metadata") - .join("socs") - .join("alif") - .join("ensemble"); - std::fs::create_dir_all(&soc_dir).unwrap(); - std::fs::write( - soc_dir.join("e8.json"), - r#"{ - "soc_spec_version": 1, - "ref": "alif:ensemble:e8", - "vendor": "Alif Semiconductor", - "family": "Ensemble", - "part": "E8", - "variants": [ - { - "order_code": "AE822FA0E5597LS0", - "debug": { - "pyocd_target": "AE822FA0E5597LS0", - "jlink_device": {"m55_hp": "Cortex-M55", "m55_he": "Cortex-M55"} - } - } - ] - }"#, - ) - .unwrap(); - - let mut g = global(&dir); - g.sdk_root = Some(sdk.to_string_lossy().into_owned()); - g.format = Format::Json; - let args = DebugConfigArgs { - core: Some("m55_hp".to_string()), - target_kind: Some("zephyr-mcu".to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: None, - preview: false, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - let json: serde_json::Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - - // The overwrite happened (matches a real build's own resolution - // behaviour — unchanged by this PR). - assert_eq!(json["data"]["configuration"]["device"], "Cortex-M55"); - let on_disk: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(vscode_dir.join("launch.json")).unwrap()) - .unwrap(); - assert_eq!(on_disk["configurations"][0]["device"], "Cortex-M55"); - - // …and it was DISCLOSED, not silent. - let issues = json["issues"].as_array().expect("issues array"); - let overwrite_issue = issues - .iter() - .find(|i| i["code"] == "debug-config.sdk-identity-overwrite") - .unwrap_or_else(|| panic!("no overwrite issue in {issues:?}")); - assert_eq!(overwrite_issue["severity"], "info"); - let message = overwrite_issue["message"].as_str().unwrap(); - assert!(message.contains("AE822FA0E5597LS0_M55_HE"), "{message}"); - assert!(message.contains("Cortex-M55"), "{message}"); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// alp-sdk#1026 review finding #3: `jlink_device` is keyed BY core id, so - /// on a project that has never been built AND passes no `--core`, - /// `identity_core` is `None` and `device` must stay the placeholder — - /// there is no core to index the map with, and no "only entry" guess. - /// `targetId` (pyOCD) is the opposite case, already covered by - /// `debug_config_resolves_pyocd_target_id_from_sdk_metadata_pre_build` - /// above (a scalar, needs no core at all). - #[test] - fn debug_config_jlink_device_stays_the_placeholder_with_no_core_and_no_build() { - let dir = tmp("sdk-metadata-fallback-no-core-jlink"); - std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); - - let sdk = dir.join("sdk"); - std::fs::create_dir_all(sdk.join("scripts")).unwrap(); - std::fs::write(sdk.join("scripts").join("alp_project.py"), "").unwrap(); - let som_dir = sdk.join("metadata").join("e1m_modules"); - std::fs::create_dir_all(&som_dir).unwrap(); - std::fs::write( - som_dir.join("E1M-AEN801.yaml"), - "schema_version: 1\nsku: E1M-AEN801\nsilicon: alif:ensemble:e8\n\ - silicon_variant: AE822FA0E5597LS0\n", - ) - .unwrap(); - let soc_dir = sdk - .join("metadata") - .join("socs") - .join("alif") - .join("ensemble"); - std::fs::create_dir_all(&soc_dir).unwrap(); - std::fs::write( - soc_dir.join("e8.json"), - r#"{ - "soc_spec_version": 1, - "ref": "alif:ensemble:e8", - "vendor": "Alif Semiconductor", - "family": "Ensemble", - "part": "E8", - "variants": [ - { - "order_code": "AE822FA0E5597LS0", - "debug": { - "pyocd_target": "AE822FA0E5597LS0", - "jlink_device": {"m55_hp": "Cortex-M55", "m55_he": "Cortex-M55"} - } - } - ] - }"#, - ) - .unwrap(); - - let mut g = global(&dir); - g.sdk_root = Some(sdk.to_string_lossy().into_owned()); - g.format = Format::Json; - let args = DebugConfigArgs { - core: None, // no --core, and no build ever ran either. - target_kind: Some("zephyr-mcu".to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: None, - preview: true, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - let json: serde_json::Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - assert_eq!(json["data"]["configuration"]["device"], ""); - - let _ = std::fs::remove_dir_all(&dir); - } - - // The `:` hole in the note logic: a yocto draft whose - // `` DID resolve has no `:` — the - // note goes silent on exactly the config that cannot launch. - #[test] - fn the_placeholder_note_survives_an_unresolved_host_port() { - let mut draft = create_launch_draft( - DebugTargetKind::YoctoUserspace, - DebugServerKind::Gdbserver, - None, - ) - .unwrap(); - apply_launch_resolution( - &mut draft, - &LaunchResolution { - gdb_path: Some("/opt/gdb/bin/aarch64-poky-linux-gdb".into()), - ..Default::default() - }, - ); - assert_eq!(draft["miDebuggerServerAddress"], ":"); - assert!(!has_placeholder(&draft["miDebuggerPath"])); - - let notes = preview_notes_for(&draft, &[], DebugServerKind::Gdbserver); - assert!( - notes.iter().any(|n| n.starts_with("Placeholder fields")), - "an unresolved : must keep the note: {notes:?}" - ); - } - - /// Write a `system-manifest.yaml` at `/build/system-manifest.yaml`. - fn write_manifest(workspace: &Path, yaml: &str) { - let build_dir = workspace.join("build"); - std::fs::create_dir_all(&build_dir).unwrap(); - std::fs::write(build_dir.join("system-manifest.yaml"), yaml).unwrap(); - } - - /// A manifest with a Cortex-M Zephyr MCU slice FIRST and a `native_sim` - /// slice SECOND — the exact ordering that broke `native-host` resolution - /// before this fix (the old `os`-keyed match took the first `os: zephyr` - /// slice, which on this manifest is the MCU one, not the host binary). - /// - /// BOTH slices record `zephyr.elf`, because that is the only thing tan - /// ever writes: `resolve_zephyr_artefact` (build/execute/manifest.rs) - /// stores `/build/zephyr/zephyr.elf` unconditionally, with no - /// `.exe` branch for native_sim, and alp-sdk NEVER writes `output_artefact` - /// at all. This fixture originally wrote `zephyr.exe` on the native_sim - /// slice — a manifest tan cannot produce — which is precisely why it - /// could not see that `resolve_from_build` was taking the ELF verbatim. - /// - /// That `.elf` claim is not prose here: it is pinned on the PRODUCER side - /// by `build::execute::manifest`'s - /// `resolve_zephyr_artefact_names_the_elf_even_for_a_native_sim_slice`. - /// If a `.exe` branch is ever added there, that test fails and this - /// fixture gets revisited — rather than both silently drifting back to - /// encoding a manifest tan cannot produce, which is the blind spot itself - /// and not merely #83's instance of it. - fn manifest_mcu_then_native_sim(workspace: &Path) -> String { - let root = workspace.to_string_lossy().replace('\\', "/"); - format!( - "schema_version: 1\nhw_info:\n sku: E1M-AEN701\nslices:\n\ - - core_id: m55_hp\n os: zephyr\n board: alp_e1m_aen701_m55_hp\n status: ok\n \ - output_artefact: {root}/build/m55_hp-zephyr/build/zephyr/zephyr.elf\n\ - - core_id: native_sim\n os: zephyr\n board: native_sim\n status: ok\n \ - output_artefact: {root}/build/native_sim-zephyr/build/zephyr/zephyr.elf\n\ - ipc: []\nhelper_mcus: []\nboot_order: []\n" - ) - } - - // Regression for the actual bug: with a Cortex-M Zephyr slice FIRST and a - // `native_sim` slice second, `native-host` resolution must take the - // native_sim artefact, never fall through to the MCU one — the old - // `os`-keyed match took the first `os: zephyr` slice regardless of which - // one it was, pointing `Alp: Native Sim Debug` at a Cortex-M ELF. - // - // And it must resolve the RUNNABLE: the slice records `zephyr.elf` (all - // tan ever writes), so `program` has to be the sibling `zephyr.exe`. - // Taking `output_artefact` verbatim hands CodeLLDB an ELF it cannot - // launch — the same class of failure, one directory entry over. - #[test] - fn native_host_resolves_native_sim_slice_not_the_first_zephyr_slice() { - let dir = tmp("native-host-mixed"); - write_manifest(&dir, &manifest_mcu_then_native_sim(&dir)); - - let (resolution, _, _) = resolve_from_build( - &dir, - DebugTargetKind::NativeHost, - DebugServerKind::None, - None, - ); - - assert_eq!( - resolution.executable.as_deref(), - Some("${workspaceFolder}/build/native_sim-zephyr/build/zephyr/zephyr.exe") - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - // With ONLY a Cortex-M Zephyr slice (no `native_sim` slice at all), - // `native-host` resolution must resolve NO executable rather than adopt - // the MCU ELF — the draft keeps its own placeholder `program`. - #[test] - fn native_host_resolves_nothing_when_manifest_has_no_native_sim_slice() { - let dir = tmp("native-host-mcu-only"); - let root = dir.to_string_lossy().replace('\\', "/"); - let manifest = format!( - "schema_version: 1\nhw_info:\n sku: E1M-AEN701\nslices:\n\ - - core_id: m55_hp\n os: zephyr\n board: alp_e1m_aen701_m55_hp\n status: ok\n \ - output_artefact: {root}/build/m55_hp-zephyr/build/zephyr/zephyr.elf\n\ - ipc: []\nhelper_mcus: []\nboot_order: []\n" - ); - write_manifest(&dir, &manifest); - - let (resolution, _, _) = resolve_from_build( - &dir, - DebugTargetKind::NativeHost, - DebugServerKind::None, - None, - ); - - assert_eq!(resolution.executable, None); - - let _ = std::fs::remove_dir_all(&dir); - } - - // `zephyr-mcu` behaviour is unchanged by the native-host fix: on the same - // two-slice manifest, bare resolution still takes the first `os: zephyr` - // slice (the MCU one, listed first), and `--core` still pins a specific - // slice explicitly. - #[test] - fn zephyr_mcu_resolution_unchanged_by_the_native_host_fix() { - let dir = tmp("zephyr-mcu-unchanged"); - write_manifest(&dir, &manifest_mcu_then_native_sim(&dir)); - - let (bare, _, _) = resolve_from_build( - &dir, - DebugTargetKind::ZephyrMcu, - DebugServerKind::Jlink, - None, - ); - assert_eq!( - bare.executable.as_deref(), - Some("${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf") - ); - - let (pinned, _, _) = resolve_from_build( - &dir, - DebugTargetKind::ZephyrMcu, - DebugServerKind::Jlink, - Some("m55_hp"), - ); - assert_eq!( - pinned.executable.as_deref(), - Some("${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf") - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - // Zephyr's qualified board form (`native_sim/native/64`) must still be - // recognised for `native-host` resolution, not just the bare `native_sim` - // name — otherwise the fix would quietly depend on a board string real - // manifests don't always use. - #[test] - fn native_host_resolves_qualified_native_sim_board_form() { - let dir = tmp("native-host-qualified-board"); - let root = dir.to_string_lossy().replace('\\', "/"); - let manifest = format!( - "schema_version: 1\nhw_info:\n sku: E1M-AEN701\nslices:\n\ - - core_id: native_sim\n os: zephyr\n board: native_sim/native/64\n status: \ - ok\n output_artefact: {root}/build/native_sim-zephyr/build/zephyr/zephyr.elf\n\ - ipc: []\nhelper_mcus: []\nboot_order: []\n" - ); - write_manifest(&dir, &manifest); - - let (resolution, _, _) = resolve_from_build( - &dir, - DebugTargetKind::NativeHost, - DebugServerKind::None, - None, - ); - - assert_eq!( - resolution.executable.as_deref(), - Some("${workspaceFolder}/build/native_sim-zephyr/build/zephyr/zephyr.exe") - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - // Bug 1 at the command boundary: the envelope's `data.configuration` — the - // very object alp-sdk-vscode#342 writes into launch.json — must carry no - // `preLaunchTask` unless one was asked for. Nothing in this repo, in - // alp-sdk-vscode, or in a generated project defines a task, and VS Code - // aborts pre-launch on a name it cannot resolve, so a default here means - // the emitted configuration cannot start a session at all. - #[test] - fn envelope_configuration_carries_a_pre_launch_task_only_when_opted_in() { - let dir = tmp("prelaunch-optin"); - let mut g = global(&dir); - g.format = Format::Json; - let mut args = DebugConfigArgs { - core: None, - target_kind: Some("zephyr-mcu".to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: None, - preview: true, - }; - - let default_json = run(&g, &args).json.expect("json envelope"); - assert!( - !default_json.contains("preLaunchTask"), - "default debug-config output must not name a task nothing defines: -{default_json}" - ); - - args.pre_launch_task = Some("alpRun: build".to_string()); - let opted_in: Value = serde_json::from_str(&run(&g, &args).json.expect("json envelope")) - .expect("envelope is JSON"); - assert_eq!( - opted_in["data"]["configuration"]["preLaunchTask"], - "alpRun: build" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// #133 reopened, driven end-to-end through `run()`: the exact reported - /// transcript — a hand-filled `"device": "AE822F4M55_HP"` sitting on the - /// orphaned legacy `"ALP: Zephyr Debug (J-Link)"` entry. Asserts the value - /// survives onto the correctly-named entry (both in the returned envelope - /// AND in the file actually written to disk), and that the run reports - /// the migration as an `issues[]` entry rather than silently rewriting the - /// customer's file. - #[test] - fn run_migrates_a_legacy_alp_entry_and_reports_it_as_an_issue() { - let dir = tmp("migrate-legacy"); - let vscode_dir = dir.join(".vscode"); - std::fs::create_dir_all(&vscode_dir).unwrap(); - let launch_json = vscode_dir.join("launch.json"); - std::fs::write( - &launch_json, - serde_json::to_string_pretty(&serde_json::json!({ - "version": "0.2.0", - "configurations": [{ - "name": "ALP: Zephyr Debug (J-Link)", - "type": "cortex-debug", - "request": "launch", - "cwd": "${workspaceFolder}", - "executable": "${workspaceFolder}/build/app/zephyr/zephyr.elf", - "servertype": "jlink", - "device": "AE822F4M55_HP", - "interface": "swd", - }], - })) - .unwrap(), - ) - .unwrap(); - - let mut g = global(&dir); - g.format = Format::Json; - let args = DebugConfigArgs { - core: None, - target_kind: Some("zephyr-mcu".to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: None, - preview: false, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - - let envelope: Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - // tan-cli#180: `data.configuration` now reports the MERGED result — - // the customer's real, hand-filled `device` — not the fresh draft's - // own `` placeholder. Before the fix this read - // `""` here even though the file on disk (checked - // below) already carried the real value, so the envelope told a - // consumer the write had NOT resolved something it plainly had. - assert_eq!( - envelope["data"]["configuration"]["name"], - "Alp: Zephyr Debug (J-Link)" - ); - assert_eq!( - envelope["data"]["configuration"]["device"], "AE822F4M55_HP", - "the envelope must report what was actually written, not the \ - draft's stale placeholder: {envelope}" - ); - assert_eq!(envelope["data"]["replaced"], true); - let issues = envelope["issues"].as_array().unwrap(); - assert_eq!(issues.len(), 1, "{envelope}"); - assert_eq!(issues[0]["code"], "debug-config.legacy-entry-migrated"); - assert_eq!(issues[0]["severity"], "info"); - assert!( - issues[0]["message"] - .as_str() - .unwrap() - .contains("ALP: Zephyr Debug (J-Link)"), - "{envelope}" - ); - - // The actual file on disk, not just the in-memory draft, carries the - // migrated after-state. - let after: Value = - serde_json::from_str(&std::fs::read_to_string(&launch_json).unwrap()).unwrap(); - let configs = after["configurations"].as_array().unwrap(); - assert_eq!( - configs.len(), - 1, - "the legacy entry must be adopted in place, not left behind: {after}" - ); - assert_eq!(configs[0]["name"], "Alp: Zephyr Debug (J-Link)"); - assert_eq!(configs[0]["device"], "AE822F4M55_HP"); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// The failing-case pairing #133 asks for: on a workspace with NO legacy - /// entry at all (the common case — a fresh `.vscode/launch.json`), the - /// migration issue must never appear. A test that only proves migration - /// happens when it should, with nothing proving it does not happen when it - /// should not, would pass a version that unconditionally attaches the - /// issue. - #[test] - fn run_emits_no_migration_issue_when_no_legacy_entry_exists() { - let dir = tmp("no-migration"); - let mut g = global(&dir); - g.format = Format::Json; - let args = DebugConfigArgs { - core: None, - target_kind: Some("native-host".to_string()), - server: None, - pre_launch_task: None, - svd: None, - preview: false, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - - let envelope: Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - assert_eq!( - envelope["issues"].as_array().unwrap().len(), - 0, - "a fresh launch.json must not report a migration that never happened: {envelope}" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// The migration notice is printed in TEXT mode even under `--quiet` - /// (`global()` sets `quiet: true`) — this is a one-time, meaningful notice - /// about a file change under the customer's feet, not routine resolution - /// noise that `--quiet` is meant to suppress. - #[test] - fn text_mode_reports_the_migration_even_when_quiet() { - let dir = tmp("migrate-legacy-text"); - let vscode_dir = dir.join(".vscode"); - std::fs::create_dir_all(&vscode_dir).unwrap(); - std::fs::write( - vscode_dir.join("launch.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "version": "0.2.0", - "configurations": [{ - "name": "ALP: Native Sim Debug", - "type": "lldb", - "request": "launch", - "program": "${workspaceFolder}/build/native_sim/zephyr/zephyr.exe", - "cwd": "${workspaceFolder}", - }], - })) - .unwrap(), - ) - .unwrap(); - - let g = global(&dir); - assert!(g.quiet, "this test only proves something if quiet is set"); - let args = DebugConfigArgs { - core: None, - target_kind: Some("native-host".to_string()), - server: None, - pre_launch_task: None, - svd: None, - preview: false, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - assert!( - run_result - .text - .iter() - .any(|l| l.contains("Migrated the legacy launch-configuration entry")), - "{:?}", - run_result.text - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// tan-cli#182 review finding #2, at the command boundary: a write that - /// drops a comment inside the entry being updated must surface - /// `debug-config.comments-dropped` as an `issues[]` entry, severity - /// `info`, not just succeed silently — #182's own non-negotiable floor. - #[test] - fn run_reports_a_comments_dropped_issue_when_a_write_drops_one() { - let dir = tmp("comments-dropped-issue"); - let vscode_dir = dir.join(".vscode"); - std::fs::create_dir_all(&vscode_dir).unwrap(); - std::fs::write( - vscode_dir.join("launch.json"), - "{\n \"version\": \"0.2.0\",\n \"configurations\": [\n {\n \"name\": \"Alp: Zephyr Debug (J-Link)\",\n \"type\": \"cortex-debug\",\n \"request\": \"launch\",\n // hand-picked after bring-up\n \"cwd\": \"${workspaceFolder}\",\n \"executable\": \"${workspaceFolder}/build/app/zephyr/zephyr.elf\",\n \"servertype\": \"jlink\",\n \"device\": \"OLD_DEVICE\",\n \"interface\": \"swd\"\n }\n ]\n}\n", - ) - .unwrap(); - - let mut g = global(&dir); - g.format = Format::Json; - let args = DebugConfigArgs { - core: None, - target_kind: Some("zephyr-mcu".to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: None, - preview: false, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - - let envelope: Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - let issues = envelope["issues"].as_array().unwrap(); - let found = issues - .iter() - .find(|i| i["code"] == "debug-config.comments-dropped") - .unwrap_or_else(|| panic!("no comments-dropped issue: {envelope}")); - assert_eq!(found["severity"], "info"); - - let after = std::fs::read_to_string(vscode_dir.join("launch.json")).unwrap(); - assert!( - !after.contains("hand-picked after bring-up"), - "the fixture must actually have dropped the comment for this test \ - to prove anything: {after}" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// The failing-case pairing: an ordinary re-run against a comment-free - /// file (the common case) must never report `comments-dropped`. - #[test] - fn run_emits_no_comments_dropped_issue_on_an_ordinary_write() { - let dir = tmp("no-comments-dropped"); - let mut g = global(&dir); - g.format = Format::Json; - let args = DebugConfigArgs { - core: None, - target_kind: Some("native-host".to_string()), - server: None, - pre_launch_task: None, - svd: None, - preview: false, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - - let envelope: Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - assert!( - envelope["issues"] - .as_array() - .unwrap() - .iter() - .all(|i| i["code"] != "debug-config.comments-dropped"), - "a fresh write with nothing to drop must not report dropping anything: {envelope}" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// tan-cli#180, the preview-side guard: `--preview` never reads or writes - /// the customer's file (it returns before the read), so it must keep - /// reporting the fresh draft even when a legacy entry that WOULD migrate - /// on a real write sits right there in `.vscode/launch.json`. This is - /// exactly the invariant the four `debug-config-preview-*` goldens pin — - /// a regression here would move all four for the wrong reason. - #[test] - fn preview_mode_reports_the_draft_even_when_a_legacy_entry_would_migrate() { - let dir = tmp("preview-ignores-legacy"); - let vscode_dir = dir.join(".vscode"); - std::fs::create_dir_all(&vscode_dir).unwrap(); - std::fs::write( - vscode_dir.join("launch.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "version": "0.2.0", - "configurations": [{ - "name": "ALP: Zephyr Debug (J-Link)", - "type": "cortex-debug", - "servertype": "jlink", - "device": "AE822F4M55_HP", - }], - })) - .unwrap(), - ) - .unwrap(); - - let mut g = global(&dir); - g.format = Format::Json; - let args = DebugConfigArgs { - core: None, - target_kind: Some("zephyr-mcu".to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: None, - preview: true, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - - let envelope: Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - assert_eq!( - envelope["data"]["configuration"]["device"], "", - "preview must report the draft's own placeholder, never a value \ - implying a merge that never ran: {envelope}" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// Every `--svd` test passes an ABSOLUTE path on purpose. `resolve_user_svd` - /// anchors a relative path on the process cwd, and cargo runs these tests - /// in threads that share one cwd — a `set_current_dir` here would race - /// every other test in the binary. The cwd anchoring is documented on the - /// flag and exercised by hand, not by a test that can flake. - fn args_with_svd(target_kind: &str, svd: Option<&str>, preview: bool) -> DebugConfigArgs { - DebugConfigArgs { - core: None, - target_kind: Some(target_kind.to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: svd.map(str::to_string), - preview, - } - } - - #[test] - fn a_user_supplied_svd_inside_the_project_is_emitted_workspace_relative() { - let dir = tmp("svd-in-project"); - let svd = dir.join("E8.svd"); - std::fs::write(&svd, "").unwrap(); - - let mut g = global(&dir); - g.format = Format::Json; - let args = args_with_svd("zephyr-mcu", Some(&svd.to_string_lossy()), true); - let run_result = run(&g, &args); - - assert_eq!(run_result.exit, ExitCode::Success); - let envelope: Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - let config = &envelope["data"]["configuration"]; - // Both keys, because cortex-debug has spelled it both ways across - // versions and the draft carries both. - assert_eq!(config["svdFile"], "${workspaceFolder}/E8.svd"); - assert_eq!(config["svdPath"], "${workspaceFolder}/E8.svd"); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_user_supplied_svd_outside_the_project_stays_absolute() { - let dir = tmp("svd-outside-project"); - let vendor = tmp("svd-vendor-sdk"); - let svd = vendor.join("AE722F80F55D5AS.svd"); - std::fs::write(&svd, "").unwrap(); - - let mut g = global(&dir); - g.format = Format::Json; - let args = args_with_svd("zephyr-mcu", Some(&svd.to_string_lossy()), true); - let run_result = run(&g, &args); - - assert_eq!(run_result.exit, ExitCode::Success); - let envelope: Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - // The normal case: a vendor SVD lives in the vendor SDK, not the - // project, so it must NOT be mangled into a ${workspaceFolder} path. - assert_eq!( - envelope["data"]["configuration"]["svdFile"], - Value::String(normalize_path(&svd).to_string_lossy().into_owned()) - ); - - let _ = std::fs::remove_dir_all(&dir); - let _ = std::fs::remove_dir_all(&vendor); - } - - #[test] - fn a_missing_svd_path_fails_instead_of_silently_dropping_the_key() { - let dir = tmp("svd-missing"); - let missing = dir.join("nope.svd"); - - let g = global(&dir); - let args = args_with_svd("zephyr-mcu", Some(&missing.to_string_lossy()), false); - let run_result = run(&g, &args); - - // Falling back to "no SVD" would make a typo indistinguishable from - // not passing the flag — the user explicitly named this file. - assert_eq!(run_result.exit, ExitCode::InternalFailure); - assert!( - !dir.join(".vscode").join("launch.json").exists(), - "a refused --svd must not have written launch.json" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// tan-cli#179, driven end-to-end through `run()`: the "dangerous branch" - /// repro (a maintained `"Alp: ..."` entry AND a leftover - /// `"ALP: ..."` one, both present) must surface a - /// `debug-config.legacy-entry-untouched` issue naming the leftover entry. - #[test] - fn run_reports_a_leftover_legacy_entry_left_untouched_by_the_ordinary_merge() { - let dir = tmp("legacy-untouched"); - let vscode_dir = dir.join(".vscode"); - std::fs::create_dir_all(&vscode_dir).unwrap(); - std::fs::write( - vscode_dir.join("launch.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "version": "0.2.0", - "configurations": [ - { - "name": "Alp: Zephyr Debug (J-Link)", - "type": "cortex-debug", - "servertype": "jlink", - "device": "", - }, - { - "name": "ALP: Zephyr Debug (J-Link)", - "type": "cortex-debug", - "servertype": "jlink", - "device": "AE822F4M55_HP", - }, - ], - })) - .unwrap(), - ) - .unwrap(); - - let mut g = global(&dir); - g.format = Format::Json; - let args = DebugConfigArgs { - core: None, - target_kind: Some("zephyr-mcu".to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: None, - preview: false, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - - let envelope: Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - let issues = envelope["issues"].as_array().unwrap(); - let found = issues - .iter() - .find(|i| i["code"] == "debug-config.legacy-entry-untouched") - .unwrap_or_else(|| panic!("no legacy-entry-untouched issue: {envelope}")); - assert_eq!(found["severity"], "info"); - assert!( - found["message"] - .as_str() - .unwrap() - .contains("ALP: Zephyr Debug (J-Link)"), - "{envelope}" - ); - // No migration happened -- the maintained entry merged ordinarily. - assert!( - issues - .iter() - .all(|i| i["code"] != "debug-config.legacy-entry-migrated"), - "{envelope}" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// tan-cli#170: `project.boardYaml` must report a resolvable `board.yaml` - /// instead of hardcoding `null` on a success — same resolver every other - /// command (`bootstrap`, `doctor`, `presets`, …) already uses. - #[test] - fn envelope_reports_the_projects_board_yaml_when_one_exists() { - let dir = tmp("board-yaml-reported"); - std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); - - let mut g = global(&dir); - g.format = Format::Json; - let args = DebugConfigArgs { - core: None, - target_kind: Some("zephyr-mcu".to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: None, - preview: true, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - - let envelope: Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - let board_yaml = envelope["project"]["boardYaml"] - .as_str() - .unwrap_or_else(|| panic!("project.boardYaml must be populated: {envelope}")); - assert!( - board_yaml.ends_with("board.yaml"), - "expected a path ending in board.yaml, got {board_yaml}" - ); - // #170's own rationale, applied: `project.root` and `project.boardYaml` - // must not ship with different separators in the same object. - let root = envelope["project"]["root"].as_str().unwrap_or_default(); - assert_eq!( - board_yaml.contains('\\'), - root.contains('\\'), - "root and boardYaml disagree on separator: {envelope}" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// tan-cli#236, the pair of the test above: #170's fix routed this field - /// through the shared resolver, which builds `/board.yaml` - /// unconditionally — so without #236 it traded a hardcoded null for a path - /// to a file that need not exist. `debug-config` succeeds in a directory - /// with no `board.yaml` (the four golden previews all do), which makes it - /// the command where the wrong value is most reachable. - #[test] - fn envelope_reports_a_null_board_yaml_when_the_directory_has_none() { - let dir = tmp("board-yaml-absent"); - assert!(!dir.join("board.yaml").exists()); - - let mut g = global(&dir); - g.format = Format::Json; - let args = DebugConfigArgs { - core: None, - target_kind: Some("zephyr-mcu".to_string()), - server: Some("jlink".to_string()), - pre_launch_task: None, - svd: None, - preview: true, - }; - let run_result = run(&g, &args); - assert_eq!(run_result.exit, ExitCode::Success); - - let envelope: Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - assert!( - envelope["project"]["boardYaml"].is_null(), - "no board.yaml is there -- the field must not name one: {envelope}" - ); - // `root` is deliberately untouched: #236 rules it out of scope, and a - // run still legitimately reports where it stood. - assert!( - envelope["project"]["root"].is_string(), - "root must still report the resolved directory: {envelope}" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn an_svd_path_that_is_a_directory_is_refused() { - let dir = tmp("svd-is-a-dir"); - let not_a_file = dir.join("svd-dir"); - std::fs::create_dir_all(¬_a_file).unwrap(); - - let g = global(&dir); - let args = args_with_svd("zephyr-mcu", Some(¬_a_file.to_string_lossy()), true); - - assert_eq!(run(&g, &args).exit, ExitCode::InternalFailure); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn an_empty_svd_path_is_refused_rather_than_treated_as_absent() { - let dir = tmp("svd-empty"); - let g = global(&dir); - let args = args_with_svd("zephyr-mcu", Some(" "), true); - - assert_eq!(run(&g, &args).exit, ExitCode::InternalFailure); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn svd_on_a_target_kind_without_the_field_is_reported_not_silently_ignored() { - let dir = tmp("svd-non-mcu"); - let svd = dir.join("E8.svd"); - std::fs::write(&svd, "").unwrap(); - - let mut g = global(&dir); - g.format = Format::Json; - let mut args = args_with_svd("native-host", Some(&svd.to_string_lossy()), true); - args.server = None; - let run_result = run(&g, &args); - - assert_eq!(run_result.exit, ExitCode::Success); - let envelope: Value = - serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); - assert!( - envelope["data"]["configuration"].get("svdFile").is_none(), - "a native-host draft has no svdFile field to fill" - ); - let notes = envelope["data"]["notes"].as_array().unwrap(); - assert!( - notes - .iter() - .any(|n| n.as_str().unwrap_or_default().contains("--svd was given")), - "accepting --svd here and saying nothing is the silent no-op this note exists to \ - prevent: {notes:?}" - ); - - let _ = std::fs::remove_dir_all(&dir); - } -} +// SPDX-License-Identifier: Apache-2.0 +//! `tan debug-config` — generate (or preview) a VS Code launch.json entry. +//! +//! Mirrors TS `runDebugConfigCommand`: build a launch draft for the target/ +//! server, then either preview it (`--preview`) or merge it into +//! `/.vscode/launch.json`. Invalid kind / unsupported backend → +//! exit 5; a failed write → exit 3. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use tan_core::run::{native_sim_exe_beside, native_sim_slice}; +use tan_core::runners::{parse_runners_config, runner_arg_value, runner_arg_values}; +use tan_core::size::{SocVariant, resolve_variant}; +use tan_core::system_manifest::{Slice, SystemManifest, parse_system_manifest}; +use tan_core::{ + DebugServerKind, DebugTargetKind, LaunchResolution, ProjectContext, apply_launch_resolution, + create_launch_draft, create_launch_json_write_plan, fill_debug_probe_identity_gaps, + is_unresolved_placeholder, launch_preview_document, launch_preview_notes, parse_board_model, + parse_server_kind, parse_target_kind, +}; + +use super::CommandRun; +use crate::cli::{DebugConfigArgs, GlobalArgs}; +use crate::envelope::{Envelope, Issue, Project}; +use crate::exit::ExitCode; +use crate::util::{generated_at_iso, normalize_path, resolve_cli_project_context_no_sdk_report}; + +/// `data` payload of the `debug-config` envelope (serialized as camelCase JSON). +#[derive(serde::Serialize)] +struct DebugConfigData { + /// Envelope data-schema version (currently `"1"`). + #[serde(rename = "schemaVersion")] + schema_version: String, + /// ISO-8601 generation timestamp. + #[serde(rename = "generatedAt")] + generated_at: String, + /// Resolved debug target kind. + #[serde(rename = "targetKind")] + target_kind: DebugTargetKind, + /// Resolved debug server backend. + server: DebugServerKind, + /// `true` when previewing only (no write performed). + preview: bool, + /// Path to the `.vscode/launch.json` that was (or would be) written. + #[serde(rename = "launchJsonPath")] + launch_json_path: String, + /// `true` when an existing launch config was replaced rather than appended. + replaced: bool, + /// Human-readable preview/usage notes. + notes: Vec, + /// The launch configuration itself — the very thing the command produces. + /// Additive: the envelope used to describe the write (path, replaced, + /// notes) without carrying the object, so an automated consumer had to + /// re-read `launch.json` or scrape the text preview to see what was + /// generated (alp-sdk-vscode#339). + configuration: Value, +} + +/// Entry point for `tan debug-config`: parse target/server, build the launch +/// draft, then preview it (`--preview`) or merge it into `.vscode/launch.json`. +pub fn run(g: &GlobalArgs, args: &DebugConfigArgs) -> CommandRun { + let generated_at = generated_at_iso(); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + + // Errors before workspace resolution report a cwd-based launch.json path + // and a zephyr-mcu/none placeholder (matches the TS catch block). + let cwd_launch_path = || { + cwd.join(".vscode") + .join("launch.json") + .to_string_lossy() + .to_string() + }; + + let target = match parse_target_kind(args.target_kind.as_deref()) { + Ok(t) => t, + Err(message) => return internal_failure(g, &generated_at, message, cwd_launch_path()), + }; + let server = match parse_server_kind(args.server.as_deref()) { + Ok(s) => s, + Err(message) => return internal_failure(g, &generated_at, message, cwd_launch_path()), + }; + let mut draft = match create_launch_draft(target, server, args.pre_launch_task.as_deref()) { + Ok(d) => d, + Err(message) => return internal_failure(g, &generated_at, message, cwd_launch_path()), + }; + + let project_arg = g.project.clone().unwrap_or_else(|| ".".to_string()); + let workspace_root = normalize_path(&cwd.join(&project_arg)); + let launch_json_path = workspace_root + .join(".vscode") + .join("launch.json") + .to_string_lossy() + .to_string(); + + // tan-cli#170: every other command's `Project.root`/`Project.board_yaml` + // come from this SAME shared resolver (`bootstrap`, `doctor`, `presets`, + // `validate`, …); `debug-config` was the one holdout still hardcoding + // `board_yaml: None` on every path, even a success with a valid + // `board.yaml` sitting in the resolved root. Bound once (not just for + // `board_yaml_path`) so the reported `project.root` is this SAME + // `context.workspace_root` — already posix-normalized, like every other + // command's golden — instead of the locally-computed `workspace_root: + // PathBuf` below's native `to_string_lossy()`, which put a + // native-backslash `root` next to a forward-slash `boardYaml` in the same + // envelope object on Windows (#170 follow-up). Reporting-only — no + // consumer binds either field yet. The `_no_sdk_report` variant: unlike + // every other caller of this resolver, `debug-config` does not DRIVE the + // SDK the way `build`/`size`/`validate` do, so it must not add an + // undeclared `sdk` envelope key as a side effect of a field it merely + // reports (tan-cli#111 follow-up). alp-sdk#1026's metadata fallback below + // (`fill_debug_probe_identity_from_sdk`) does now best-effort READ under + // `context.sdk_root` when one resolves — that stays a silent, optional + // enrichment exactly like the `board/system-manifest.yaml` read already + // was, not a new reported dependency, so the choice not to record here + // is unchanged. + let context = resolve_cli_project_context_no_sdk_report(g); + // + // tan-cli#236 completes it: built through the shared constructor, so + // `boardYaml` is null when nothing is actually at the resolved path. + // Routing `board_yaml` through the resolver (above) without this would have + // traded a hardcoded null for a path to a file that need not exist — the + // same field disagreeing with the filesystem, in the other direction. + let project = Project::from_context(&context); + + // Fill the `` placeholders from what this project's own build + // recorded (#66). Nothing here fails the command: pre-build, or against a + // Zephyr that reshaped `runners.yaml`, the draft keeps its placeholders. + let (mut resolution, registered_runners, build_core_id) = + resolve_from_build(&workspace_root, target, server, args.core.as_deref()); + + // alp-sdk#1026: whatever the build did NOT already resolve, try the SDK's + // published per-variant debug-probe identity next — `--core` if given, + // else the core id the build itself just resolved. `targetId` (pyOCD) + // needs neither: `pyocd_target` is a scalar per variant, so it resolves + // pre-build with no `--core` and no prior build at all. `device` (J-Link) + // is the opposite: `jlink_device` is keyed BY core id, so on a + // never-built project with no `--core`, `identity_core` is `None` and + // `device` stays the placeholder — that combination is deliberately + // covered by a test (`fill_debug_probe_identity_gaps_never_guesses_a_device_without_a_matching_core_id` + // in `tan_core::debug_launch`, and `debug_config_jlink_device_stays_the_placeholder_with_no_core_and_no_build` + // here) rather than left silently unresolved with no coverage. + let identity_core = args.core.clone().or(build_core_id); + let before_identity_fill = resolution.clone(); + let identity_debug_block_found = + fill_debug_probe_identity_from_sdk(&mut resolution, &context, identity_core.as_deref()); + // Which launch-configuration JSON keys the SDK fallback (not a real + // build) just populated — the ONLY fields `sdk_identity_overwrites` below + // is allowed to flag (alp-sdk#1026 review finding #1). A field a real + // build already resolved is excluded here even though it may ALSO + // overwrite a customer's value: that overwrite is pre-existing, intended + // behaviour (`merge_configuration`'s own doc comment), not something this + // PR introduces or is scoped to disclose. + let mut sdk_filled_json_fields: Vec<&'static str> = Vec::new(); + if before_identity_fill.device.is_none() && resolution.device.is_some() { + sdk_filled_json_fields.push("device"); + } + if before_identity_fill.target_id.is_none() && resolution.target_id.is_some() { + sdk_filled_json_fields.push("targetId"); + } + if before_identity_fill.config_files.is_empty() && !resolution.config_files.is_empty() { + sdk_filled_json_fields.push("configFiles"); + } + + // `--svd` is the ONLY producer of `resolution.svd` (tan-cli#197): the SDK + // ships no SVD, so without the flag the field is structurally always + // `None` and `apply_launch_resolution` drops both svd keys. + if let Some(svd_arg) = args.svd.as_deref() { + match resolve_user_svd(&cwd, &workspace_root, svd_arg) { + Ok(svd) => resolution.svd = Some(svd), + Err(message) => { + return internal_failure(g, &generated_at, message, launch_json_path); + } + } + } + + apply_launch_resolution(&mut draft, &resolution); + let mut notes = preview_notes_for(&draft, ®istered_runners, server); + // A non-MCU draft carries no `svdFile` key at all, and + // `apply_launch_resolution` only replaces keys that already exist — so a + // `--svd` here is a no-op. Say so rather than accepting the flag in + // silence and leaving the user to wonder why no peripheral view appeared. + if args.svd.is_some() && draft.get("svdFile").is_none() { + notes.push(format!( + "--svd was given, but target kind '{}' emits no svdFile field, so it had no effect: \ + the Cortex Peripherals view is a cortex-debug (MCU) feature.", + args.target_kind.as_deref().unwrap_or("zephyr-mcu"), + )); + } + + // alp-sdk#1026 review finding #4: the generic "Placeholder fields..." + // note is real but unspecific — running `--server openocd` today gives + // `issues: []` / `ok: true` with the only signal being a note that names + // `device`, a key an OpenOCD draft does not even carry. When the SDK DID + // resolve an identity for this variant but not the specific field THIS + // server needs (every Alif variant today, for `openocd_config`), say so + // explicitly — on preview too, not just a write, since this is advisory + // about resolution state, not about what a write changed on disk. + let mut identity_issues: Vec = Vec::new(); + if identity_debug_block_found { + if let Some(field) = server_identity_field(server) { + if draft.get(field).map(has_placeholder).unwrap_or(false) { + identity_issues.push(sdk_identity_key_absent_issue(field)); + } + } + } + + if args.preview { + return success( + g, + &generated_at, + target, + server, + &launch_json_path, + true, + false, + ¬es, + &draft, + project, + identity_issues, + ); + } + + // Write mode: merge into .vscode/launch.json. + let vscode_dir = Path::new(&launch_json_path) + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| workspace_root.join(".vscode")); + if let Err(e) = std::fs::create_dir_all(&vscode_dir) { + return write_failure( + g, + &generated_at, + target, + server, + &launch_json_path, + e.to_string(), + ); + } + + // `.ok()` here used to collapse a READ error on an EXISTING launch.json + // (wrong encoding e.g. UTF-16LE from PowerShell `>` redirection, a denied + // ACL, a sharing violation) into the same `None` as "no file yet". That + // fed create_launch_json_write_plan(None, ...), which builds a *fresh* + // document and the write below then overwrote the user's file wholesale + // — silently destroying every hand-written debug configuration at exit 0. + // The malformed-JSON case just below is deliberately guarded (no write); + // a read error must refuse to write for the same reason. + let existing = if Path::new(&launch_json_path).exists() { + match std::fs::read_to_string(&launch_json_path) { + Ok(content) => Some(content), + Err(e) => { + return internal_failure( + g, + &generated_at, + format!("Alp: failed to read existing .vscode/launch.json: {e}"), + cwd_launch_path(), + ); + } + } + } else { + None + }; + + // alp-sdk#1026 review finding #1: compute this BEFORE the write, against + // the file as it stood — `create_launch_json_write_plan` below already + // performs the same overwrite (that part of its behaviour is intentional, + // see its own doc comment), this only detects it so it can be disclosed. + let sdk_identity_overwrites = + tan_core::sdk_identity_overwrites(existing.as_deref(), &draft, &sdk_filled_json_fields); + + let plan = match create_launch_json_write_plan(existing.as_deref(), &draft) { + Ok(p) => p, + // A malformed existing launch.json surfaces as an internal failure in TS. + Err(message) => return internal_failure(g, &generated_at, message, cwd_launch_path()), + }; + + if let Err(e) = std::fs::write(&launch_json_path, &plan.content) { + return write_failure( + g, + &generated_at, + target, + server, + &launch_json_path, + e.to_string(), + ); + } + + // #133 reopened: report a legacy-entry migration so the customer knows + // WHY the file changed under them (their old `"ALP: ..."` entry is gone, + // folded into the correctly-named one) rather than discovering it only by + // diffing the file themselves. + let mut issues = identity_issues; + if let Some(from) = &plan.migrated_from { + issues.push(legacy_entry_migrated_issue( + from, + draft["name"].as_str().unwrap_or_default(), + )); + } + // tan-cli#179: the ordinary same-name merge left a DIFFERENT leftover + // legacy entry silently untouched — say so, even though (unlike a + // migration) nothing about the file's shape changed because of it. + if let Some(legacy) = &plan.legacy_entry_present { + issues.push(legacy_entry_untouched_issue(legacy)); + } + // #182 review finding #2: a splice or fallback write that dropped a + // comment (or trailing comma) the customer's file held must say so — + // #182 named unqualified success on a write that destroys user-authored + // content as the one thing that is never acceptable, not just "diffable". + if plan.comments_dropped { + issues.push(comments_dropped_issue()); + } + // alp-sdk#1026 review finding #1: this write just replaced a concrete + // existing value with one resolved from the SDK's published debug-probe + // identity rather than a real build — say so, the same way a dropped + // comment is disclosed rather than left for the customer to notice by + // diffing the file themselves. + for (field, existing_value, incoming_value) in &sdk_identity_overwrites { + issues.push(sdk_identity_overwrite_issue( + field, + existing_value, + incoming_value, + )); + } + + success( + g, + &generated_at, + target, + server, + &launch_json_path, + false, + plan.replaced, + ¬es, + // tan-cli#180: report what this write actually put in the file — the + // merged/migrated result — never the fresh `draft`, which still + // carries its own `` placeholders even after a merge + // resolved them from the customer's real, hand-filled values. + &plan.written_configuration, + project, + issues, + ) +} + +/// The #133 migration report: emitted when a pre-#155 `"ALP: ..."` entry was +/// found in place of the current `"Alp: ..."` name and adopted onto it (see +/// `tan_core::debug_launch::create_launch_json_write_plan`). Severity `info`, +/// not `warning` or `error` — nothing failed and no action is required; this +/// exists so an automated consumer (or a customer reading `--format json`) +/// can tell WHY the file changed under them instead of only diffing it. +fn legacy_entry_migrated_issue(from: &str, to: &str) -> Issue { + Issue { + code: "debug-config.legacy-entry-migrated".to_string(), + severity: "info".to_string(), + message: format!( + "Migrated the legacy launch-configuration entry \"{from}\" into \"{to}\". \ + Any value you had hand-filled in on the old entry for an unresolved-\ + placeholder field (device, miDebuggerServerAddress, configFiles, …) \ + carried across; every other field tan owns was refreshed to this run's \ + values, same as an ordinary re-run. The old entry is gone." + ), + } +} + +/// tan-cli#179: emitted when the ORDINARY same-name merge ran (an exact hit +/// against the current `"Alp: ..."` name) and a legacy `"ALP: ..."` +/// counterpart of the SAME draft ALSO still sits in the file. Distinct from +/// [`legacy_entry_migrated_issue`], which fires on the MISS path where the +/// legacy entry is the one adopted — here NEITHER entry was touched beyond +/// the ordinary merge, so the customer's real hand-filled values may still be +/// stranded on the leftover entry with nothing pointing at it. Severity +/// `info`, same reasoning as the migration notice: nothing failed and there +/// is no forced action, but silence here is exactly the #133 symptom the +/// customer hits next. +fn legacy_entry_untouched_issue(legacy_name: &str) -> Issue { + Issue { + code: "debug-config.legacy-entry-untouched".to_string(), + severity: "info".to_string(), + message: format!( + "A leftover legacy launch-configuration entry \"{legacy_name}\" still sits in \ + .vscode/launch.json alongside the entry this run updated. It was left \ + untouched — nothing decides which of the two you may have hand-edited is \ + authoritative — so if you filled in real values on the legacy entry, copy \ + them onto the maintained one and remove the legacy entry yourself." + ), + } +} + +/// tan-cli#182 review finding #2: emitted whenever this write dropped a +/// comment (or trailing comma) sitting inside a byte span it rewrote — the +/// one maintained entry a splice replaced, or, on the whole-document +/// fallback, the customer's entire original file. Severity `info`, same as +/// [`legacy_entry_migrated_issue`]: nothing failed and there is no action to +/// take, but a tool that discarded user-authored content must never report +/// unqualified success (#182's own non-negotiable floor). +fn comments_dropped_issue() -> Issue { + Issue { + code: "debug-config.comments-dropped".to_string(), + severity: "info".to_string(), + message: "This write dropped a comment (or trailing comma) that sat inside the \ + part of .vscode/launch.json it rewrote — either inside the one entry \ + being updated, or, if the file's shape couldn't be confidently \ + spliced, anywhere in the file. Everything outside that span is \ + untouched." + .to_string(), + } +} + +/// alp-sdk#1026 review finding #1: emitted whenever the SDK's published +/// debug-probe identity (not a real build) just replaced a concrete existing +/// value on the entry this run wrote. Severity `info`, same reasoning as its +/// three siblings above: the overwrite itself is not new or wrong (a value +/// resolved from a real build already overwrote unconditionally, by design — +/// see `tan_core::debug_launch::merge_configuration`'s doc comment) but a +/// tool that replaces a customer's own value at `exit 0` with `issues: []` +/// has told them nothing happened. +fn sdk_identity_overwrite_issue(field: &str, existing_value: &str, incoming_value: &str) -> Issue { + Issue { + code: "debug-config.sdk-identity-overwrite".to_string(), + severity: "info".to_string(), + message: format!( + "This write replaced the existing `{field}` value \"{existing_value}\" with \ + \"{incoming_value}\", resolved from the SDK's published debug-probe identity \ + (alp-sdk#987) rather than from a real build. If \"{existing_value}\" was a value \ + you filled in on purpose — e.g. a J-Link flash-unlock device profile more specific \ + than the generic attach device the SDK publishes — restore it in \ + .vscode/launch.json; a value tan itself resolves from a real build will overwrite \ + it again the same way." + ), + } +} + +/// alp-sdk#1026 review finding #4: emitted when the SDK DID publish a +/// debug-probe identity for this project's SoC variant, but that identity +/// does not (yet) include a value for `field` — distinct from, and more +/// specific than, the generic "Placeholder fields..." note every unresolved +/// field already gets regardless of why. Severity `info`: this is the +/// schema's own documented stance (`soc-spec-v1.schema.json:379`) that an +/// unpopulated key is a published "unknown", not an error and not a bug. +fn sdk_identity_key_absent_issue(field: &str) -> Issue { + Issue { + code: "debug-config.sdk-identity-key-absent".to_string(), + severity: "info".to_string(), + message: format!( + "This SoM's SDK-published debug-probe identity (alp-sdk#987) does not include a \ + value for `{field}` yet, so it stays the placeholder shown in `configuration` — an \ + unpopulated key is the correct published \"unknown\" (alp-sdk#1026), never a guess." + ), + } +} + +/// Build a success `CommandRun`: emit the JSON envelope (or text lines) for a +/// completed preview or write at `ExitCode::Success`. +/// +/// `configuration` is the launch configuration to REPORT — the caller decides +/// which one that is. `--preview` never merges anything (it returns before +/// the customer's file is even read), so it passes the fresh draft, which is +/// also all there is. A write passes the write plan's own +/// `written_configuration` instead (tan-cli#180): the merged/migrated result +/// that actually landed on disk, not the draft's stale `` +/// placeholders a merge may have already overwritten with the customer's +/// real values. +#[allow(clippy::too_many_arguments)] +fn success( + g: &GlobalArgs, + generated_at: &str, + target: DebugTargetKind, + server: DebugServerKind, + launch_json_path: &str, + preview: bool, + replaced: bool, + notes: &[String], + configuration: &Value, + project: Project, + issues: Vec, +) -> CommandRun { + let data = DebugConfigData { + schema_version: "1".to_string(), + generated_at: generated_at.to_string(), + target_kind: target, + server, + preview, + launch_json_path: launch_json_path.to_string(), + replaced, + notes: notes.to_vec(), + configuration: configuration.clone(), + }; + let text = if g.is_json() { + Vec::new() + } else { + debug_config_text( + target, + server, + launch_json_path, + replaced, + preview, + notes, + configuration, + g, + &issues, + ) + }; + let json = g.is_json().then(|| { + Envelope::new( + "debug-config", + project, + data, + issues, + ExitCode::Success.code(), + ) + .to_json() + }); + CommandRun { + exit: ExitCode::Success, + text, + json, + } +} + +/// Failure `CommandRun` for invalid kind / unsupported backend / malformed +/// existing launch.json: exits `InternalFailure` (5) with a `zephyr-mcu`/`none` +/// placeholder target. +fn internal_failure( + g: &GlobalArgs, + generated_at: &str, + message: String, + launch_json_path: String, +) -> CommandRun { + failure_envelope( + g, + generated_at, + DebugTargetKind::ZephyrMcu, + DebugServerKind::None, + launch_json_path, + ExitCode::InternalFailure, + "internal-failure", + message, + vec!["debug-config: internal failure".to_string()], + ) +} + +/// Failure `CommandRun` for a filesystem error while creating the directory or +/// writing launch.json: exits `WriteFailure` (3), preserving the resolved +/// target/server. +fn write_failure( + g: &GlobalArgs, + generated_at: &str, + target: DebugTargetKind, + server: DebugServerKind, + launch_json_path: &str, + message: String, +) -> CommandRun { + failure_envelope( + g, + generated_at, + target, + server, + launch_json_path.to_string(), + ExitCode::WriteFailure, + "write-failure", + message, + vec!["debug-config: failed to write launch.json.".to_string()], + ) +} + +/// Shared failure path: assemble the issue + `data` payload, emit text or a +/// null-project JSON envelope, and return a `CommandRun` at the given `exit`. +#[allow(clippy::too_many_arguments)] +fn failure_envelope( + g: &GlobalArgs, + generated_at: &str, + target: DebugTargetKind, + server: DebugServerKind, + launch_json_path: String, + exit: ExitCode, + code: &str, + message: String, + mut text_lines: Vec, +) -> CommandRun { + let issues = vec![Issue { + code: format!("debug-config.{code}"), + severity: "error".to_string(), + message: message.clone(), + }]; + let data = DebugConfigData { + schema_version: "1".to_string(), + generated_at: generated_at.to_string(), + target_kind: target, + server, + preview: false, + launch_json_path, + replaced: false, + notes: Vec::new(), + // No draft exists on this path — the failure happened before (or + // instead of) generating one. `null`, not an empty object, so a + // consumer cannot mistake it for a configuration with no fields. + configuration: Value::Null, + }; + let text = if g.is_json() { + Vec::new() + } else { + text_lines.push(message); + text_lines + }; + // TS createFailureResult reports a null project. + let json = g.is_json().then(|| { + Envelope::new( + "debug-config", + Project { + root: None, + board_yaml: None, + }, + data, + issues, + exit.code(), + ) + .to_json() + }); + CommandRun { exit, text, json } +} + +/// Render the human-readable (non-JSON) output lines for a successful preview +/// or write, including the pretty-printed launch document and notes unless +/// `--quiet`. +#[allow(clippy::too_many_arguments)] +fn debug_config_text( + target: DebugTargetKind, + server: DebugServerKind, + launch_json_path: &str, + replaced: bool, + preview: bool, + notes: &[String], + draft: &Value, + g: &GlobalArgs, + issues: &[Issue], +) -> Vec { + let mut lines = Vec::new(); + if preview { + lines.push(format!( + "debug-config: preview target={} server={}", + target.as_str(), + server.as_str() + )); + lines.push(format!("launch.json path: {launch_json_path}")); + if !g.quiet { + lines.push(String::new()); + let document = launch_preview_document(draft.clone()); + lines.push(serde_json::to_string_pretty(&document).unwrap_or_default()); + lines.push(String::new()); + lines.extend(notes.iter().map(|n| format!("note: {n}"))); + } + } else { + let action = if replaced { "updated" } else { "written" }; + lines.push(format!( + "debug-config: {action} target={} server={}", + target.as_str(), + server.as_str() + )); + lines.push(format!("launch.json: {launch_json_path}")); + // Always shown, even under --quiet: this is a one-time notice that the + // file just lost a differently-named entry (folded into this one), not + // routine noise like the resolution notes below it. + for issue in issues { + if issue.code == "debug-config.legacy-entry-migrated" { + lines.push(format!("debug-config: {}", issue.message)); + } + } + // tan-cli#179: same treatment — a leftover legacy entry sitting + // untouched next to the one this run just updated is exactly the + // kind of fact that must survive --quiet, not routine resolution + // noise. + for issue in issues { + if issue.code == "debug-config.legacy-entry-untouched" { + lines.push(format!("debug-config: {}", issue.message)); + } + } + // Same treatment for a dropped comment/trailing comma (#182 review + // finding #2): a notice about content this run destroyed is never + // routine noise, so it survives --quiet too. + for issue in issues { + if issue.code == "debug-config.comments-dropped" { + lines.push(format!("note: {}", issue.message)); + } + } + if !g.quiet { + lines.extend(notes.iter().map(|n| format!("note: {n}"))); + } + } + lines +} + +/// The manifest `os` a debug target class runs on, or `None` for a target with +/// no per-core build slice keyed by `os`. `NativeHost` is exactly that case — +/// its slice is selected by board target instead, in [`select_slice`]. +fn manifest_os_for_target(target: DebugTargetKind) -> Option<&'static str> { + match target { + DebugTargetKind::ZephyrMcu => Some("zephyr"), + DebugTargetKind::BaremetalMcu => Some("baremetal"), + DebugTargetKind::YoctoUserspace => Some("yocto"), + DebugTargetKind::NativeHost => None, + } +} + +/// Select the manifest slice a debug draft resolves against, for a given +/// target/`--core`. +/// +/// `NativeHost` is a special case: its runnable artefact is the project's +/// `native_sim` slice, found by board target via +/// [`tan_core::run::native_sim_slice`] — the SAME discriminator `tan run` +/// uses to pick the host binary — not by `os`. A board that also builds a +/// real Zephyr MCU slice still has one or more slices with `os: zephyr`; the +/// old `os`-keyed match took the first of those, which on such a board is +/// often the MCU slice, pointing `Alp: Native Sim Debug` at a Cortex-M ELF +/// that CodeLLDB then can't launch on the host. `--core` is intentionally +/// unused on this arm: a `native_sim` slice's `core_id` is not a hardware +/// core selector. +/// +/// Every other target kind keeps the existing `os` + `--core` match. +fn select_slice<'a>( + manifest: &'a SystemManifest, + target: DebugTargetKind, + core: Option<&str>, +) -> Option<&'a Slice> { + if target == DebugTargetKind::NativeHost { + return native_sim_slice(manifest); + } + let os = manifest_os_for_target(target)?; + // `--core` names the slice outright; otherwise the first slice of this + // target's OS wins, which is the whole manifest on a single-core project. + manifest + .slices + .iter() + .find(|s| s.os == os && core.map(|c| s.core_id == c).unwrap_or(true)) +} + +/// The `runners.yaml` runner id a debug server reads its arguments from. +fn runner_id_for_server(server: DebugServerKind) -> Option<&'static str> { + match server { + DebugServerKind::Jlink => Some("jlink"), + DebugServerKind::Openocd => Some("openocd"), + DebugServerKind::Pyocd => Some("pyocd"), + DebugServerKind::Gdbserver | DebugServerKind::None => None, + } +} + +/// The launch-configuration JSON key the SDK's debug-probe identity +/// (`variants[].debug`) resolves for a given server — `None` for a server the +/// identity has no concept of at all (`gdbserver`/`none`, neither of which +/// `create_launch_draft` ever pairs with a `variants[].debug` field). +fn server_identity_field(server: DebugServerKind) -> Option<&'static str> { + match server { + DebugServerKind::Jlink => Some("device"), + DebugServerKind::Openocd => Some("configFiles"), + DebugServerKind::Pyocd => Some("targetId"), + DebugServerKind::Gdbserver | DebugServerKind::None => None, + } +} + +/// Rewrite a path under `workspace_root` as `${workspaceFolder}/`, so a +/// committed `launch.json` stays portable; an artefact outside the project +/// (an out-of-tree build root) is left absolute rather than mangled. +fn workspace_relative(workspace_root: &Path, path: &str) -> String { + Path::new(path) + .strip_prefix(workspace_root) + .ok() + .map(|rel| format!("${{workspaceFolder}}/{}", rel.to_string_lossy())) + .unwrap_or_else(|| path.to_string()) +} + +/// Resolve `--svd` into the value the launch configuration should carry. +/// +/// **Anchor: the current directory, not the project root.** `--svd` is a +/// per-invocation flag typed at a shell prompt, so a relative path means what +/// the shell means by it. (A board-level `debug.svd` key, should one ever be +/// added, travels with the project and must anchor on the project root +/// instead — the two have different lifetimes, so they get different anchors +/// deliberately rather than by omission.) The emitted string then goes through +/// the same [`workspace_relative`] rewrite as `executable`: inside the project +/// it becomes `${workspaceFolder}/…` so a committed launch.json stays +/// portable, outside it stays absolute — which is the normal case here, since +/// a vendor SVD lives in the vendor SDK the user installed. +/// +/// **A bad path is a HARD ERROR, never a silent drop back to "no SVD".** +/// tan-cli#67 established that cortex-debug fails the whole session on an +/// `svdFile` it cannot read, which is why the *unresolved* case drops the key. +/// But the user explicitly named this file: falling back would make a typo +/// indistinguishable from not passing the flag, and the failure would surface +/// as an unexplained empty peripheral view. Fail here, where the message can +/// name the path. +fn resolve_user_svd(cwd: &Path, workspace_root: &Path, arg: &str) -> Result { + if arg.trim().is_empty() { + return Err("Alp: --svd was given an empty path.".to_string()); + } + // `join` on an absolute `arg` replaces the base, so this handles both. + let candidate = normalize_path(&cwd.join(arg)); + let meta = std::fs::metadata(&candidate).map_err(|e| { + format!( + "Alp: --svd path cannot be read: {} ({e}). \ + Pass the path to the vendor's own .svd file; the SDK ships none (alp-sdk#948).", + candidate.display(), + ) + })?; + if !meta.is_file() { + return Err(format!( + "Alp: --svd path is not a file: {}", + candidate.display(), + )); + } + Ok(workspace_relative( + workspace_root, + &candidate.to_string_lossy(), + )) +} + +/// Everything this project's own build knows about how to debug it: the +/// per-core ELF from `system-manifest.yaml`, and the probe/tool paths from that +/// slice's `runners.yaml` — the same file `west flash` reads. +/// +/// Best-effort throughout. A missing manifest (pre-build), a missing slice, an +/// unreadable or reshaped `runners.yaml` each leave the corresponding field +/// unresolved instead of failing the command: `debug-config` must still emit +/// its draft before the first build. +/// +/// The third return value is the `core_id` of the slice this run actually +/// selected (`None` before a matching slice is found) — the SAME id `--core` +/// would have named explicitly. alp-sdk#1026's SDK-metadata fallback (see +/// `fill_debug_probe_identity_from_sdk`) needs it to index `jlink_device` +/// (keyed per core) even when the caller passed no `--core` of its own, so a +/// single-core project's ALREADY-built slice still resolves without forcing +/// the user to repeat a core id `tan` already knows. +fn resolve_from_build( + workspace_root: &Path, + target: DebugTargetKind, + server: DebugServerKind, + core: Option<&str>, +) -> (LaunchResolution, Vec, Option) { + let mut resolution = LaunchResolution::default(); + let manifest_path = workspace_root.join("build").join("system-manifest.yaml"); + let Ok(yaml) = std::fs::read_to_string(&manifest_path) else { + return (resolution, Vec::new(), None); + }; + let Ok(manifest) = parse_system_manifest(&yaml) else { + return (resolution, Vec::new(), None); + }; + let Some(slice) = select_slice(&manifest, target, core) else { + return (resolution, Vec::new(), None); + }; + let core_id = Some(slice.core_id.clone()); + + if let Some(artefact) = slice.output_artefact.as_deref().filter(|a| !a.is_empty()) { + // A manifest records the ELF for EVERY zephyr slice, native_sim + // included: `resolve_zephyr_artefact` (build/execute/manifest.rs) is + // tan's only writer of `output_artefact` and stores + // `/build/zephyr/zephyr.elf` unconditionally — there is no + // `.exe` branch, and alp-sdk (planner-only) never writes the field at + // all. + // So a host target needs the sibling swap, via the same tan-core + // helper `tan run` uses; every other target kind genuinely wants the + // artefact verbatim. #83 took it verbatim here too, which pointed + // `Alp: Native Sim Debug` at a `zephyr.elf` CodeLLDB cannot launch. + let artefact = match target { + DebugTargetKind::NativeHost => native_sim_exe_beside(artefact), + _ => artefact.to_string(), + }; + resolution.executable = Some(workspace_relative(workspace_root, &artefact)); + } + + let Some(build_dir) = slice.build_dir.as_deref().filter(|b| !b.is_empty()) else { + return (resolution, Vec::new(), core_id); + }; + let runners_path = Path::new(build_dir).join("zephyr").join("runners.yaml"); + let Ok(text) = std::fs::read_to_string(&runners_path) else { + return (resolution, Vec::new(), core_id); + }; + let Ok(runners) = parse_runners_config(&text) else { + return (resolution, Vec::new(), core_id); + }; + + resolution.gdb_path = runners.gdb.clone(); + if let Some(runner) = runner_id_for_server(server) { + match server { + DebugServerKind::Jlink => { + resolution.device = runner_arg_value(&runners, runner, "--device"); + } + DebugServerKind::Openocd => { + resolution.server_path = runners.openocd.clone(); + resolution.search_dirs = runners.openocd_search.clone(); + resolution.config_files = runner_arg_values(&runners, runner, "--config"); + } + DebugServerKind::Pyocd => { + resolution.target_id = runner_arg_value(&runners, runner, "--target"); + } + DebugServerKind::Gdbserver | DebugServerKind::None => {} + } + } + (resolution, runners.runners.clone(), core_id) +} + +/// alp-sdk#1026: fill `resolution`'s remaining `device`/`target_id`/ +/// `config_files` gaps from the SDK's published per-variant debug-probe +/// identity (`variants[].debug`, alp-sdk#987), so `tan debug-config` resolves +/// a real J-Link device / pyOCD target before the project has ever been +/// built — the case `resolve_from_build`'s `runners.yaml` read structurally +/// cannot cover. +/// +/// Reuses the SAME metadata-layout walk `tan size` drives +/// (`crate::util::read_sdk_som_and_soc`) instead of a second walk of +/// `metadata/socs/**` — the exact drift #1026 itself is about (a schema with +/// no reader, then two readers that could disagree). Pure fill-the-gap logic +/// is `fill_debug_probe_identity_gaps` (`tan_core::debug_launch`); everything +/// here is the IO side: locating `board.yaml`, reading `som.sku` out of it, +/// then the shared SoM-preset/SoC-JSON read and (unlike `tan size`) a +/// forward-only `resolve_variant` match. +/// +/// Best-effort throughout, exactly like `resolve_from_build`: a missing +/// `board.yaml`/`som.sku`, no resolved SDK root, a missing/unreadable SoM +/// preset or SoC-JSON file, or a SoC variant that resolves but declares no +/// `debug` block each leave `resolution` exactly as it was — the caller's +/// existing placeholder note still applies, and nothing here can fail the +/// command. +/// +/// Returns whether a `variants[].debug` block was actually found for the +/// resolved SoC variant — distinct from whether every field this run wanted +/// got filled from it. `run` uses this (alp-sdk#1026 review finding #4) to +/// tell "the SDK publishes an identity for this part, but not a value for +/// the specific field this server needs yet" (e.g. every Alif variant today, +/// for `openocd_config`) apart from "no identity was resolvable at all" — +/// only the former is worth a dedicated notice; the latter is already the +/// generic "still needs resolution" note every unresolved field gets. +fn fill_debug_probe_identity_from_sdk( + resolution: &mut LaunchResolution, + context: &ProjectContext, + core_id: Option<&str>, +) -> bool { + let Some(board_yaml_path) = context.board_yaml_path.as_deref() else { + return false; + }; + let Ok(board_text) = std::fs::read_to_string(board_yaml_path) else { + return false; + }; + let Ok(model) = parse_board_model(&board_text) else { + return false; + }; + let Some(sku) = model.som.and_then(|som| som.sku) else { + return false; + }; + let Some(sdk_root) = context.sdk_root.as_deref() else { + return false; + }; + let metadata_root = Path::new(sdk_root).join("metadata"); + + // Shared metadata-layout walk with `tan size` — see `read_sdk_som_and_soc`'s + // doc comment. `sku: None` here (unlike `tan size`) deliberately disables + // `resolve_variant`'s sku reverse-fallback: a drifted/`TBD` preset must + // resolve NO identity rather than possibly a WRONG one that still + // connects a live debug session to the wrong part (alp-sdk#1026 review + // finding #7) — a missing budget is a lesser harm than a wrong device. + let Some((preset, soc)) = crate::util::read_sdk_som_and_soc(&metadata_root, &sku) else { + return false; + }; + let variants: Vec = soc + .get("variants") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + let Some(variant) = resolve_variant(preset.silicon_variant.as_deref(), None, &variants) else { + return false; + }; + let Some(debug) = variant.debug.as_ref() else { + return false; + }; + fill_debug_probe_identity_gaps( + resolution, + core_id, + &debug.jlink_device, + debug.pyocd_target.as_deref(), + debug.openocd_config.as_deref(), + ); + true +} + +/// Whether any `<…>` placeholder survived resolution, anywhere in the draft — +/// including inside `configFiles`, which is an array. +/// +/// The string test is [`is_unresolved_placeholder`], the SAME predicate the +/// launch.json merge uses, so "keep the still-needs-resolution note" and "do +/// not overwrite this by hand-filled value" can never disagree. It used to be +/// `s.contains(":` a +/// real address: a yocto config whose `` resolved then dropped +/// the note while `miDebuggerServerAddress` was still unusable. +fn has_placeholder(value: &Value) -> bool { + match value { + Value::String(s) => is_unresolved_placeholder(s), + Value::Array(items) => items.iter().any(has_placeholder), + Value::Object(map) => map.values().any(has_placeholder), + _ => false, + } +} + +/// The preview notes, minus the "still needs resolution" warning once nothing +/// is left to resolve. Keyed off the FINAL draft rather than off "did anything +/// resolve": a partly-resolved config (a board that registers no OpenOCD runner +/// still has ``) must keep the warning, and a fully +/// resolved one must lose it — otherwise the note is noise on configs that are +/// fine and silence on configs that are not. +fn preview_notes_for( + draft: &Value, + registered_runners: &[String], + server: DebugServerKind, +) -> Vec { + let mut notes: Vec = launch_preview_notes() + .into_iter() + .filter(|n| !n.starts_with("Placeholder fields") || has_placeholder(draft)) + .collect(); + // The most common reason a placeholder survives: the board never registered + // this server. Say so, instead of leaving the user to wonder which project- + // specific value they are supposed to invent. + if let Some(runner) = runner_id_for_server(server) { + if !registered_runners.is_empty() && !registered_runners.iter().any(|r| r == runner) { + notes.push(format!( + "This build registers no '{runner}' runner (runners.yaml: {registered_runners:?}), \ + so its fields could not be resolved.", + )); + } + } + notes +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Format; + + fn tmp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("tan-debug-config-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn global(project: &Path) -> GlobalArgs { + GlobalArgs { + project: Some(project.to_string_lossy().into_owned()), + board_yaml: None, + sdk_root: None, + target: None, + all: false, + format: Format::Text, + verbose: false, + quiet: true, + no_color: true, + non_interactive: true, + ci: false, + } + } + + // Regression for the data-loss bug: a read error on an EXISTING + // launch.json (here, non-UTF-8 bytes as PowerShell `>` redirection would + // produce) must refuse to write, exactly like the malformed-JSON case. + // Before the fix, `.ok()` turned the read Err into `None`, which was + // treated as "no file yet" and the write below overwrote it wholesale. + #[test] + fn unreadable_existing_launch_json_refuses_to_write() { + let dir = tmp("unreadable"); + let vscode_dir = dir.join(".vscode"); + std::fs::create_dir_all(&vscode_dir).unwrap(); + let launch_json = vscode_dir.join("launch.json"); + let not_utf8: &[u8] = &[0xFF, 0xFE, b'{', 0, b'}', 0]; + std::fs::write(&launch_json, not_utf8).unwrap(); + + let g = global(&dir); + let args = DebugConfigArgs { + core: None, + target_kind: Some("zephyr-mcu".to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: None, + preview: false, + }; + let run_result = run(&g, &args); + + assert_eq!(run_result.exit, ExitCode::InternalFailure); + let after = std::fs::read(&launch_json).unwrap(); + assert_eq!( + after, not_utf8, + "an unreadable existing launch.json must be left untouched, not overwritten" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// alp-sdk#1026 end-to-end: with NO build at all (no `system-manifest.yaml`, + /// no `runners.yaml`), a `board.yaml` naming a SoM and an SDK checkout + /// publishing that SoM's variant `debug` block, `device`/`targetId` must + /// resolve from the SDK metadata rather than staying the placeholder -- + /// the exact gap #1026 reports as inert. + #[test] + fn debug_config_resolves_device_and_target_id_from_sdk_metadata_pre_build() { + let dir = tmp("sdk-metadata-fallback"); + std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); + + let sdk = dir.join("sdk"); + std::fs::create_dir_all(sdk.join("scripts")).unwrap(); + std::fs::write(sdk.join("scripts").join("alp_project.py"), "").unwrap(); + let som_dir = sdk.join("metadata").join("e1m_modules"); + std::fs::create_dir_all(&som_dir).unwrap(); + std::fs::write( + som_dir.join("E1M-AEN801.yaml"), + "schema_version: 1\nsku: E1M-AEN801\nsilicon: alif:ensemble:e8\n\ + silicon_variant: AE822FA0E5597LS0\n", + ) + .unwrap(); + let soc_dir = sdk + .join("metadata") + .join("socs") + .join("alif") + .join("ensemble"); + std::fs::create_dir_all(&soc_dir).unwrap(); + std::fs::write( + soc_dir.join("e8.json"), + r#"{ + "soc_spec_version": 1, + "ref": "alif:ensemble:e8", + "vendor": "Alif Semiconductor", + "family": "Ensemble", + "part": "E8", + "variants": [ + { + "order_code": "AE822FA0E5597LS0", + "debug": { + "pyocd_target": "AE822FA0E5597LS0", + "jlink_device": {"m55_hp": "Cortex-M55", "m55_he": "Cortex-M55"} + } + } + ] + }"#, + ) + .unwrap(); + + let mut g = global(&dir); + g.sdk_root = Some(sdk.to_string_lossy().into_owned()); + g.format = Format::Json; + let args = DebugConfigArgs { + core: Some("m55_hp".to_string()), + target_kind: Some("zephyr-mcu".to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: None, + preview: true, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + let json: serde_json::Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + assert_eq!(json["data"]["configuration"]["device"], "Cortex-M55"); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The `--server pyocd` sibling of the test above: `targetId` resolves + /// from `pyocd_target`, and needs no `--core` at all (`jlink_device` is + /// the only per-core field; `pyocd_target` is a scalar per variant). + #[test] + fn debug_config_resolves_pyocd_target_id_from_sdk_metadata_pre_build() { + let dir = tmp("sdk-metadata-fallback-pyocd"); + std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); + + let sdk = dir.join("sdk"); + std::fs::create_dir_all(sdk.join("scripts")).unwrap(); + std::fs::write(sdk.join("scripts").join("alp_project.py"), "").unwrap(); + let som_dir = sdk.join("metadata").join("e1m_modules"); + std::fs::create_dir_all(&som_dir).unwrap(); + std::fs::write( + som_dir.join("E1M-AEN801.yaml"), + "schema_version: 1\nsku: E1M-AEN801\nsilicon: alif:ensemble:e8\n\ + silicon_variant: AE822FA0E5597LS0\n", + ) + .unwrap(); + let soc_dir = sdk + .join("metadata") + .join("socs") + .join("alif") + .join("ensemble"); + std::fs::create_dir_all(&soc_dir).unwrap(); + std::fs::write( + soc_dir.join("e8.json"), + r#"{ + "soc_spec_version": 1, + "ref": "alif:ensemble:e8", + "vendor": "Alif Semiconductor", + "family": "Ensemble", + "part": "E8", + "variants": [ + { + "order_code": "AE822FA0E5597LS0", + "debug": { + "pyocd_target": "AE822FA0E5597LS0", + "jlink_device": {"m55_hp": "Cortex-M55", "m55_he": "Cortex-M55"} + } + } + ] + }"#, + ) + .unwrap(); + + let mut g = global(&dir); + g.sdk_root = Some(sdk.to_string_lossy().into_owned()); + g.format = Format::Json; + let args = DebugConfigArgs { + core: None, + target_kind: Some("zephyr-mcu".to_string()), + server: Some("pyocd".to_string()), + pre_launch_task: None, + svd: None, + preview: true, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + let json: serde_json::Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + assert_eq!( + json["data"]["configuration"]["targetId"], + "AE822FA0E5597LS0" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// alp-sdk#987's own stance: `openocd_config` is absent from every SoC + /// family today, and that absence must stay the published "unknown" -- + /// the OpenOCD draft's `configFiles` keeps its placeholder rather than + /// inventing a config path, and the preview note says so. + #[test] + fn debug_config_openocd_config_files_stays_the_placeholder_when_the_sdk_publishes_none() { + let dir = tmp("sdk-metadata-fallback-openocd"); + std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); + + let sdk = dir.join("sdk"); + std::fs::create_dir_all(sdk.join("scripts")).unwrap(); + std::fs::write(sdk.join("scripts").join("alp_project.py"), "").unwrap(); + let som_dir = sdk.join("metadata").join("e1m_modules"); + std::fs::create_dir_all(&som_dir).unwrap(); + std::fs::write( + som_dir.join("E1M-AEN801.yaml"), + "schema_version: 1\nsku: E1M-AEN801\nsilicon: alif:ensemble:e8\n\ + silicon_variant: AE822FA0E5597LS0\n", + ) + .unwrap(); + let soc_dir = sdk + .join("metadata") + .join("socs") + .join("alif") + .join("ensemble"); + std::fs::create_dir_all(&soc_dir).unwrap(); + // No openocd_config key -- exactly every real Alif variant today. + std::fs::write( + soc_dir.join("e8.json"), + r#"{ + "soc_spec_version": 1, + "ref": "alif:ensemble:e8", + "vendor": "Alif Semiconductor", + "family": "Ensemble", + "part": "E8", + "variants": [ + { + "order_code": "AE822FA0E5597LS0", + "debug": { + "pyocd_target": "AE822FA0E5597LS0", + "jlink_device": {"m55_hp": "Cortex-M55"} + } + } + ] + }"#, + ) + .unwrap(); + + let mut g = global(&dir); + g.sdk_root = Some(sdk.to_string_lossy().into_owned()); + g.format = Format::Json; + let args = DebugConfigArgs { + core: Some("m55_hp".to_string()), + target_kind: Some("zephyr-mcu".to_string()), + server: Some("openocd".to_string()), + pre_launch_task: None, + svd: None, + preview: true, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + let json: serde_json::Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + assert_eq!( + json["data"]["configuration"]["configFiles"], + serde_json::json!([""]), + "an absent openocd_config must stay the placeholder, never a guess" + ); + assert!( + json["data"]["notes"].as_array().unwrap().iter().any(|n| n + .as_str() + .unwrap_or_default() + .starts_with("Placeholder fields")), + "the placeholder note must still be present: {}", + json["data"]["notes"] + ); + // alp-sdk#1026 review finding #4: the generic note above names + // `device`, which this OpenOCD draft does not even carry -- the + // specific, correctly-worded signal is this issue, present even on + // `--preview` since it is advisory about resolution state, not about + // a write. + let issues = json["issues"].as_array().expect("issues array"); + assert!( + issues + .iter() + .any(|i| i["code"] == "debug-config.sdk-identity-key-absent" + && i["message"] + .as_str() + .unwrap_or_default() + .contains("configFiles")), + "expected a sdk-identity-key-absent issue naming configFiles: {issues:?}" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// alp-sdk#1026 review finding #1 (data loss): a WRITE, not a preview — + /// every one of the three tests above only ever exercised `--preview`, + /// so the write path with this fallback had zero coverage. A customer's + /// `.vscode/launch.json` already holds a concrete, hand-filled `device` + /// (here, the more-specific `jlink_flash_device`-style profile a + /// customer might reasonably have copied in); the SDK's generic + /// `jlink_device` identity resolves and REPLACES it, same as a real + /// build's resolution always has — but this run must disclose that in + /// `issues[]`, not report `ok: true` / `issues: []` as if nothing + /// happened. + #[test] + fn debug_config_write_discloses_when_sdk_identity_overwrites_a_hand_filled_device() { + let dir = tmp("sdk-metadata-overwrite-disclosure"); + std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); + + let vscode_dir = dir.join(".vscode"); + std::fs::create_dir_all(&vscode_dir).unwrap(); + std::fs::write( + vscode_dir.join("launch.json"), + r#"{ + "version": "0.2.0", + "configurations": [ + { + "name": "Alp: Zephyr Debug (J-Link)", + "type": "cortex-debug", + "request": "launch", + "servertype": "jlink", + "device": "AE822FA0E5597LS0_M55_HE" + } + ] + }"#, + ) + .unwrap(); + + let sdk = dir.join("sdk"); + std::fs::create_dir_all(sdk.join("scripts")).unwrap(); + std::fs::write(sdk.join("scripts").join("alp_project.py"), "").unwrap(); + let som_dir = sdk.join("metadata").join("e1m_modules"); + std::fs::create_dir_all(&som_dir).unwrap(); + std::fs::write( + som_dir.join("E1M-AEN801.yaml"), + "schema_version: 1\nsku: E1M-AEN801\nsilicon: alif:ensemble:e8\n\ + silicon_variant: AE822FA0E5597LS0\n", + ) + .unwrap(); + let soc_dir = sdk + .join("metadata") + .join("socs") + .join("alif") + .join("ensemble"); + std::fs::create_dir_all(&soc_dir).unwrap(); + std::fs::write( + soc_dir.join("e8.json"), + r#"{ + "soc_spec_version": 1, + "ref": "alif:ensemble:e8", + "vendor": "Alif Semiconductor", + "family": "Ensemble", + "part": "E8", + "variants": [ + { + "order_code": "AE822FA0E5597LS0", + "debug": { + "pyocd_target": "AE822FA0E5597LS0", + "jlink_device": {"m55_hp": "Cortex-M55", "m55_he": "Cortex-M55"} + } + } + ] + }"#, + ) + .unwrap(); + + let mut g = global(&dir); + g.sdk_root = Some(sdk.to_string_lossy().into_owned()); + g.format = Format::Json; + let args = DebugConfigArgs { + core: Some("m55_hp".to_string()), + target_kind: Some("zephyr-mcu".to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: None, + preview: false, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + let json: serde_json::Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + + // The overwrite happened (matches a real build's own resolution + // behaviour — unchanged by this PR). + assert_eq!(json["data"]["configuration"]["device"], "Cortex-M55"); + let on_disk: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(vscode_dir.join("launch.json")).unwrap()) + .unwrap(); + assert_eq!(on_disk["configurations"][0]["device"], "Cortex-M55"); + + // …and it was DISCLOSED, not silent. + let issues = json["issues"].as_array().expect("issues array"); + let overwrite_issue = issues + .iter() + .find(|i| i["code"] == "debug-config.sdk-identity-overwrite") + .unwrap_or_else(|| panic!("no overwrite issue in {issues:?}")); + assert_eq!(overwrite_issue["severity"], "info"); + let message = overwrite_issue["message"].as_str().unwrap(); + assert!(message.contains("AE822FA0E5597LS0_M55_HE"), "{message}"); + assert!(message.contains("Cortex-M55"), "{message}"); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// alp-sdk#1026 review finding #3: `jlink_device` is keyed BY core id, so + /// on a project that has never been built AND passes no `--core`, + /// `identity_core` is `None` and `device` must stay the placeholder — + /// there is no core to index the map with, and no "only entry" guess. + /// `targetId` (pyOCD) is the opposite case, already covered by + /// `debug_config_resolves_pyocd_target_id_from_sdk_metadata_pre_build` + /// above (a scalar, needs no core at all). + #[test] + fn debug_config_jlink_device_stays_the_placeholder_with_no_core_and_no_build() { + let dir = tmp("sdk-metadata-fallback-no-core-jlink"); + std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); + + let sdk = dir.join("sdk"); + std::fs::create_dir_all(sdk.join("scripts")).unwrap(); + std::fs::write(sdk.join("scripts").join("alp_project.py"), "").unwrap(); + let som_dir = sdk.join("metadata").join("e1m_modules"); + std::fs::create_dir_all(&som_dir).unwrap(); + std::fs::write( + som_dir.join("E1M-AEN801.yaml"), + "schema_version: 1\nsku: E1M-AEN801\nsilicon: alif:ensemble:e8\n\ + silicon_variant: AE822FA0E5597LS0\n", + ) + .unwrap(); + let soc_dir = sdk + .join("metadata") + .join("socs") + .join("alif") + .join("ensemble"); + std::fs::create_dir_all(&soc_dir).unwrap(); + std::fs::write( + soc_dir.join("e8.json"), + r#"{ + "soc_spec_version": 1, + "ref": "alif:ensemble:e8", + "vendor": "Alif Semiconductor", + "family": "Ensemble", + "part": "E8", + "variants": [ + { + "order_code": "AE822FA0E5597LS0", + "debug": { + "pyocd_target": "AE822FA0E5597LS0", + "jlink_device": {"m55_hp": "Cortex-M55", "m55_he": "Cortex-M55"} + } + } + ] + }"#, + ) + .unwrap(); + + let mut g = global(&dir); + g.sdk_root = Some(sdk.to_string_lossy().into_owned()); + g.format = Format::Json; + let args = DebugConfigArgs { + core: None, // no --core, and no build ever ran either. + target_kind: Some("zephyr-mcu".to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: None, + preview: true, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + let json: serde_json::Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + assert_eq!(json["data"]["configuration"]["device"], ""); + + let _ = std::fs::remove_dir_all(&dir); + } + + // The `:` hole in the note logic: a yocto draft whose + // `` DID resolve has no `:` — the + // note goes silent on exactly the config that cannot launch. + #[test] + fn the_placeholder_note_survives_an_unresolved_host_port() { + let mut draft = create_launch_draft( + DebugTargetKind::YoctoUserspace, + DebugServerKind::Gdbserver, + None, + ) + .unwrap(); + apply_launch_resolution( + &mut draft, + &LaunchResolution { + gdb_path: Some("/opt/gdb/bin/aarch64-poky-linux-gdb".into()), + ..Default::default() + }, + ); + assert_eq!(draft["miDebuggerServerAddress"], ":"); + assert!(!has_placeholder(&draft["miDebuggerPath"])); + + let notes = preview_notes_for(&draft, &[], DebugServerKind::Gdbserver); + assert!( + notes.iter().any(|n| n.starts_with("Placeholder fields")), + "an unresolved : must keep the note: {notes:?}" + ); + } + + /// Write a `system-manifest.yaml` at `/build/system-manifest.yaml`. + fn write_manifest(workspace: &Path, yaml: &str) { + let build_dir = workspace.join("build"); + std::fs::create_dir_all(&build_dir).unwrap(); + std::fs::write(build_dir.join("system-manifest.yaml"), yaml).unwrap(); + } + + /// A manifest with a Cortex-M Zephyr MCU slice FIRST and a `native_sim` + /// slice SECOND — the exact ordering that broke `native-host` resolution + /// before this fix (the old `os`-keyed match took the first `os: zephyr` + /// slice, which on this manifest is the MCU one, not the host binary). + /// + /// BOTH slices record `zephyr.elf`, because that is the only thing tan + /// ever writes: `resolve_zephyr_artefact` (build/execute/manifest.rs) + /// stores `/build/zephyr/zephyr.elf` unconditionally, with no + /// `.exe` branch for native_sim, and alp-sdk NEVER writes `output_artefact` + /// at all. This fixture originally wrote `zephyr.exe` on the native_sim + /// slice — a manifest tan cannot produce — which is precisely why it + /// could not see that `resolve_from_build` was taking the ELF verbatim. + /// + /// That `.elf` claim is not prose here: it is pinned on the PRODUCER side + /// by `build::execute::manifest`'s + /// `resolve_zephyr_artefact_names_the_elf_even_for_a_native_sim_slice`. + /// If a `.exe` branch is ever added there, that test fails and this + /// fixture gets revisited — rather than both silently drifting back to + /// encoding a manifest tan cannot produce, which is the blind spot itself + /// and not merely #83's instance of it. + fn manifest_mcu_then_native_sim(workspace: &Path) -> String { + let root = workspace.to_string_lossy().replace('\\', "/"); + format!( + "schema_version: 1\nhw_info:\n sku: E1M-AEN701\nslices:\n\ + - core_id: m55_hp\n os: zephyr\n board: alp_e1m_aen701_m55_hp\n status: ok\n \ + output_artefact: {root}/build/m55_hp-zephyr/build/zephyr/zephyr.elf\n\ + - core_id: native_sim\n os: zephyr\n board: native_sim\n status: ok\n \ + output_artefact: {root}/build/native_sim-zephyr/build/zephyr/zephyr.elf\n\ + ipc: []\nhelper_mcus: []\nboot_order: []\n" + ) + } + + // Regression for the actual bug: with a Cortex-M Zephyr slice FIRST and a + // `native_sim` slice second, `native-host` resolution must take the + // native_sim artefact, never fall through to the MCU one — the old + // `os`-keyed match took the first `os: zephyr` slice regardless of which + // one it was, pointing `Alp: Native Sim Debug` at a Cortex-M ELF. + // + // And it must resolve the RUNNABLE: the slice records `zephyr.elf` (all + // tan ever writes), so `program` has to be the sibling `zephyr.exe`. + // Taking `output_artefact` verbatim hands CodeLLDB an ELF it cannot + // launch — the same class of failure, one directory entry over. + #[test] + fn native_host_resolves_native_sim_slice_not_the_first_zephyr_slice() { + let dir = tmp("native-host-mixed"); + write_manifest(&dir, &manifest_mcu_then_native_sim(&dir)); + + let (resolution, _, _) = resolve_from_build( + &dir, + DebugTargetKind::NativeHost, + DebugServerKind::None, + None, + ); + + assert_eq!( + resolution.executable.as_deref(), + Some("${workspaceFolder}/build/native_sim-zephyr/build/zephyr/zephyr.exe") + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + // With ONLY a Cortex-M Zephyr slice (no `native_sim` slice at all), + // `native-host` resolution must resolve NO executable rather than adopt + // the MCU ELF — the draft keeps its own placeholder `program`. + #[test] + fn native_host_resolves_nothing_when_manifest_has_no_native_sim_slice() { + let dir = tmp("native-host-mcu-only"); + let root = dir.to_string_lossy().replace('\\', "/"); + let manifest = format!( + "schema_version: 1\nhw_info:\n sku: E1M-AEN701\nslices:\n\ + - core_id: m55_hp\n os: zephyr\n board: alp_e1m_aen701_m55_hp\n status: ok\n \ + output_artefact: {root}/build/m55_hp-zephyr/build/zephyr/zephyr.elf\n\ + ipc: []\nhelper_mcus: []\nboot_order: []\n" + ); + write_manifest(&dir, &manifest); + + let (resolution, _, _) = resolve_from_build( + &dir, + DebugTargetKind::NativeHost, + DebugServerKind::None, + None, + ); + + assert_eq!(resolution.executable, None); + + let _ = std::fs::remove_dir_all(&dir); + } + + // `zephyr-mcu` behaviour is unchanged by the native-host fix: on the same + // two-slice manifest, bare resolution still takes the first `os: zephyr` + // slice (the MCU one, listed first), and `--core` still pins a specific + // slice explicitly. + #[test] + fn zephyr_mcu_resolution_unchanged_by_the_native_host_fix() { + let dir = tmp("zephyr-mcu-unchanged"); + write_manifest(&dir, &manifest_mcu_then_native_sim(&dir)); + + let (bare, _, _) = resolve_from_build( + &dir, + DebugTargetKind::ZephyrMcu, + DebugServerKind::Jlink, + None, + ); + assert_eq!( + bare.executable.as_deref(), + Some("${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf") + ); + + let (pinned, _, _) = resolve_from_build( + &dir, + DebugTargetKind::ZephyrMcu, + DebugServerKind::Jlink, + Some("m55_hp"), + ); + assert_eq!( + pinned.executable.as_deref(), + Some("${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf") + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + // Zephyr's qualified board form (`native_sim/native/64`) must still be + // recognised for `native-host` resolution, not just the bare `native_sim` + // name — otherwise the fix would quietly depend on a board string real + // manifests don't always use. + #[test] + fn native_host_resolves_qualified_native_sim_board_form() { + let dir = tmp("native-host-qualified-board"); + let root = dir.to_string_lossy().replace('\\', "/"); + let manifest = format!( + "schema_version: 1\nhw_info:\n sku: E1M-AEN701\nslices:\n\ + - core_id: native_sim\n os: zephyr\n board: native_sim/native/64\n status: \ + ok\n output_artefact: {root}/build/native_sim-zephyr/build/zephyr/zephyr.elf\n\ + ipc: []\nhelper_mcus: []\nboot_order: []\n" + ); + write_manifest(&dir, &manifest); + + let (resolution, _, _) = resolve_from_build( + &dir, + DebugTargetKind::NativeHost, + DebugServerKind::None, + None, + ); + + assert_eq!( + resolution.executable.as_deref(), + Some("${workspaceFolder}/build/native_sim-zephyr/build/zephyr/zephyr.exe") + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + // Bug 1 at the command boundary: the envelope's `data.configuration` — the + // very object alp-sdk-vscode#342 writes into launch.json — must carry no + // `preLaunchTask` unless one was asked for. Nothing in this repo, in + // alp-sdk-vscode, or in a generated project defines a task, and VS Code + // aborts pre-launch on a name it cannot resolve, so a default here means + // the emitted configuration cannot start a session at all. + #[test] + fn envelope_configuration_carries_a_pre_launch_task_only_when_opted_in() { + let dir = tmp("prelaunch-optin"); + let mut g = global(&dir); + g.format = Format::Json; + let mut args = DebugConfigArgs { + core: None, + target_kind: Some("zephyr-mcu".to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: None, + preview: true, + }; + + let default_json = run(&g, &args).json.expect("json envelope"); + assert!( + !default_json.contains("preLaunchTask"), + "default debug-config output must not name a task nothing defines: +{default_json}" + ); + + args.pre_launch_task = Some("alpRun: build".to_string()); + let opted_in: Value = serde_json::from_str(&run(&g, &args).json.expect("json envelope")) + .expect("envelope is JSON"); + assert_eq!( + opted_in["data"]["configuration"]["preLaunchTask"], + "alpRun: build" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// #133 reopened, driven end-to-end through `run()`: the exact reported + /// transcript — a hand-filled `"device": "AE822F4M55_HP"` sitting on the + /// orphaned legacy `"ALP: Zephyr Debug (J-Link)"` entry. Asserts the value + /// survives onto the correctly-named entry (both in the returned envelope + /// AND in the file actually written to disk), and that the run reports + /// the migration as an `issues[]` entry rather than silently rewriting the + /// customer's file. + #[test] + fn run_migrates_a_legacy_alp_entry_and_reports_it_as_an_issue() { + let dir = tmp("migrate-legacy"); + let vscode_dir = dir.join(".vscode"); + std::fs::create_dir_all(&vscode_dir).unwrap(); + let launch_json = vscode_dir.join("launch.json"); + std::fs::write( + &launch_json, + serde_json::to_string_pretty(&serde_json::json!({ + "version": "0.2.0", + "configurations": [{ + "name": "ALP: Zephyr Debug (J-Link)", + "type": "cortex-debug", + "request": "launch", + "cwd": "${workspaceFolder}", + "executable": "${workspaceFolder}/build/app/zephyr/zephyr.elf", + "servertype": "jlink", + "device": "AE822F4M55_HP", + "interface": "swd", + }], + })) + .unwrap(), + ) + .unwrap(); + + let mut g = global(&dir); + g.format = Format::Json; + let args = DebugConfigArgs { + core: None, + target_kind: Some("zephyr-mcu".to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: None, + preview: false, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + + let envelope: Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + // tan-cli#180: `data.configuration` now reports the MERGED result — + // the customer's real, hand-filled `device` — not the fresh draft's + // own `` placeholder. Before the fix this read + // `""` here even though the file on disk (checked + // below) already carried the real value, so the envelope told a + // consumer the write had NOT resolved something it plainly had. + assert_eq!( + envelope["data"]["configuration"]["name"], + "Alp: Zephyr Debug (J-Link)" + ); + assert_eq!( + envelope["data"]["configuration"]["device"], "AE822F4M55_HP", + "the envelope must report what was actually written, not the \ + draft's stale placeholder: {envelope}" + ); + assert_eq!(envelope["data"]["replaced"], true); + let issues = envelope["issues"].as_array().unwrap(); + assert_eq!(issues.len(), 1, "{envelope}"); + assert_eq!(issues[0]["code"], "debug-config.legacy-entry-migrated"); + assert_eq!(issues[0]["severity"], "info"); + assert!( + issues[0]["message"] + .as_str() + .unwrap() + .contains("ALP: Zephyr Debug (J-Link)"), + "{envelope}" + ); + + // The actual file on disk, not just the in-memory draft, carries the + // migrated after-state. + let after: Value = + serde_json::from_str(&std::fs::read_to_string(&launch_json).unwrap()).unwrap(); + let configs = after["configurations"].as_array().unwrap(); + assert_eq!( + configs.len(), + 1, + "the legacy entry must be adopted in place, not left behind: {after}" + ); + assert_eq!(configs[0]["name"], "Alp: Zephyr Debug (J-Link)"); + assert_eq!(configs[0]["device"], "AE822F4M55_HP"); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The failing-case pairing #133 asks for: on a workspace with NO legacy + /// entry at all (the common case — a fresh `.vscode/launch.json`), the + /// migration issue must never appear. A test that only proves migration + /// happens when it should, with nothing proving it does not happen when it + /// should not, would pass a version that unconditionally attaches the + /// issue. + #[test] + fn run_emits_no_migration_issue_when_no_legacy_entry_exists() { + let dir = tmp("no-migration"); + let mut g = global(&dir); + g.format = Format::Json; + let args = DebugConfigArgs { + core: None, + target_kind: Some("native-host".to_string()), + server: None, + pre_launch_task: None, + svd: None, + preview: false, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + + let envelope: Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + assert_eq!( + envelope["issues"].as_array().unwrap().len(), + 0, + "a fresh launch.json must not report a migration that never happened: {envelope}" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The migration notice is printed in TEXT mode even under `--quiet` + /// (`global()` sets `quiet: true`) — this is a one-time, meaningful notice + /// about a file change under the customer's feet, not routine resolution + /// noise that `--quiet` is meant to suppress. + #[test] + fn text_mode_reports_the_migration_even_when_quiet() { + let dir = tmp("migrate-legacy-text"); + let vscode_dir = dir.join(".vscode"); + std::fs::create_dir_all(&vscode_dir).unwrap(); + std::fs::write( + vscode_dir.join("launch.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "version": "0.2.0", + "configurations": [{ + "name": "ALP: Native Sim Debug", + "type": "lldb", + "request": "launch", + "program": "${workspaceFolder}/build/native_sim/zephyr/zephyr.exe", + "cwd": "${workspaceFolder}", + }], + })) + .unwrap(), + ) + .unwrap(); + + let g = global(&dir); + assert!(g.quiet, "this test only proves something if quiet is set"); + let args = DebugConfigArgs { + core: None, + target_kind: Some("native-host".to_string()), + server: None, + pre_launch_task: None, + svd: None, + preview: false, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + assert!( + run_result + .text + .iter() + .any(|l| l.contains("Migrated the legacy launch-configuration entry")), + "{:?}", + run_result.text + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// tan-cli#182 review finding #2, at the command boundary: a write that + /// drops a comment inside the entry being updated must surface + /// `debug-config.comments-dropped` as an `issues[]` entry, severity + /// `info`, not just succeed silently — #182's own non-negotiable floor. + #[test] + fn run_reports_a_comments_dropped_issue_when_a_write_drops_one() { + let dir = tmp("comments-dropped-issue"); + let vscode_dir = dir.join(".vscode"); + std::fs::create_dir_all(&vscode_dir).unwrap(); + std::fs::write( + vscode_dir.join("launch.json"), + "{\n \"version\": \"0.2.0\",\n \"configurations\": [\n {\n \"name\": \"Alp: Zephyr Debug (J-Link)\",\n \"type\": \"cortex-debug\",\n \"request\": \"launch\",\n // hand-picked after bring-up\n \"cwd\": \"${workspaceFolder}\",\n \"executable\": \"${workspaceFolder}/build/app/zephyr/zephyr.elf\",\n \"servertype\": \"jlink\",\n \"device\": \"OLD_DEVICE\",\n \"interface\": \"swd\"\n }\n ]\n}\n", + ) + .unwrap(); + + let mut g = global(&dir); + g.format = Format::Json; + let args = DebugConfigArgs { + core: None, + target_kind: Some("zephyr-mcu".to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: None, + preview: false, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + + let envelope: Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + let issues = envelope["issues"].as_array().unwrap(); + let found = issues + .iter() + .find(|i| i["code"] == "debug-config.comments-dropped") + .unwrap_or_else(|| panic!("no comments-dropped issue: {envelope}")); + assert_eq!(found["severity"], "info"); + + let after = std::fs::read_to_string(vscode_dir.join("launch.json")).unwrap(); + assert!( + !after.contains("hand-picked after bring-up"), + "the fixture must actually have dropped the comment for this test \ + to prove anything: {after}" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The failing-case pairing: an ordinary re-run against a comment-free + /// file (the common case) must never report `comments-dropped`. + #[test] + fn run_emits_no_comments_dropped_issue_on_an_ordinary_write() { + let dir = tmp("no-comments-dropped"); + let mut g = global(&dir); + g.format = Format::Json; + let args = DebugConfigArgs { + core: None, + target_kind: Some("native-host".to_string()), + server: None, + pre_launch_task: None, + svd: None, + preview: false, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + + let envelope: Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + assert!( + envelope["issues"] + .as_array() + .unwrap() + .iter() + .all(|i| i["code"] != "debug-config.comments-dropped"), + "a fresh write with nothing to drop must not report dropping anything: {envelope}" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// tan-cli#180, the preview-side guard: `--preview` never reads or writes + /// the customer's file (it returns before the read), so it must keep + /// reporting the fresh draft even when a legacy entry that WOULD migrate + /// on a real write sits right there in `.vscode/launch.json`. This is + /// exactly the invariant the four `debug-config-preview-*` goldens pin — + /// a regression here would move all four for the wrong reason. + #[test] + fn preview_mode_reports_the_draft_even_when_a_legacy_entry_would_migrate() { + let dir = tmp("preview-ignores-legacy"); + let vscode_dir = dir.join(".vscode"); + std::fs::create_dir_all(&vscode_dir).unwrap(); + std::fs::write( + vscode_dir.join("launch.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "version": "0.2.0", + "configurations": [{ + "name": "ALP: Zephyr Debug (J-Link)", + "type": "cortex-debug", + "servertype": "jlink", + "device": "AE822F4M55_HP", + }], + })) + .unwrap(), + ) + .unwrap(); + + let mut g = global(&dir); + g.format = Format::Json; + let args = DebugConfigArgs { + core: None, + target_kind: Some("zephyr-mcu".to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: None, + preview: true, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + + let envelope: Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + assert_eq!( + envelope["data"]["configuration"]["device"], "", + "preview must report the draft's own placeholder, never a value \ + implying a merge that never ran: {envelope}" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// Every `--svd` test passes an ABSOLUTE path on purpose. `resolve_user_svd` + /// anchors a relative path on the process cwd, and cargo runs these tests + /// in threads that share one cwd — a `set_current_dir` here would race + /// every other test in the binary. The cwd anchoring is documented on the + /// flag and exercised by hand, not by a test that can flake. + fn args_with_svd(target_kind: &str, svd: Option<&str>, preview: bool) -> DebugConfigArgs { + DebugConfigArgs { + core: None, + target_kind: Some(target_kind.to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: svd.map(str::to_string), + preview, + } + } + + #[test] + fn a_user_supplied_svd_inside_the_project_is_emitted_workspace_relative() { + let dir = tmp("svd-in-project"); + let svd = dir.join("E8.svd"); + std::fs::write(&svd, "").unwrap(); + + let mut g = global(&dir); + g.format = Format::Json; + let args = args_with_svd("zephyr-mcu", Some(&svd.to_string_lossy()), true); + let run_result = run(&g, &args); + + assert_eq!(run_result.exit, ExitCode::Success); + let envelope: Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + let config = &envelope["data"]["configuration"]; + // Both keys, because cortex-debug has spelled it both ways across + // versions and the draft carries both. + assert_eq!(config["svdFile"], "${workspaceFolder}/E8.svd"); + assert_eq!(config["svdPath"], "${workspaceFolder}/E8.svd"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_user_supplied_svd_outside_the_project_stays_absolute() { + let dir = tmp("svd-outside-project"); + let vendor = tmp("svd-vendor-sdk"); + let svd = vendor.join("AE722F80F55D5AS.svd"); + std::fs::write(&svd, "").unwrap(); + + let mut g = global(&dir); + g.format = Format::Json; + let args = args_with_svd("zephyr-mcu", Some(&svd.to_string_lossy()), true); + let run_result = run(&g, &args); + + assert_eq!(run_result.exit, ExitCode::Success); + let envelope: Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + // The normal case: a vendor SVD lives in the vendor SDK, not the + // project, so it must NOT be mangled into a ${workspaceFolder} path. + assert_eq!( + envelope["data"]["configuration"]["svdFile"], + Value::String(normalize_path(&svd).to_string_lossy().into_owned()) + ); + + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&vendor); + } + + #[test] + fn a_missing_svd_path_fails_instead_of_silently_dropping_the_key() { + let dir = tmp("svd-missing"); + let missing = dir.join("nope.svd"); + + let g = global(&dir); + let args = args_with_svd("zephyr-mcu", Some(&missing.to_string_lossy()), false); + let run_result = run(&g, &args); + + // Falling back to "no SVD" would make a typo indistinguishable from + // not passing the flag — the user explicitly named this file. + assert_eq!(run_result.exit, ExitCode::InternalFailure); + assert!( + !dir.join(".vscode").join("launch.json").exists(), + "a refused --svd must not have written launch.json" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// tan-cli#179, driven end-to-end through `run()`: the "dangerous branch" + /// repro (a maintained `"Alp: ..."` entry AND a leftover + /// `"ALP: ..."` one, both present) must surface a + /// `debug-config.legacy-entry-untouched` issue naming the leftover entry. + #[test] + fn run_reports_a_leftover_legacy_entry_left_untouched_by_the_ordinary_merge() { + let dir = tmp("legacy-untouched"); + let vscode_dir = dir.join(".vscode"); + std::fs::create_dir_all(&vscode_dir).unwrap(); + std::fs::write( + vscode_dir.join("launch.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "version": "0.2.0", + "configurations": [ + { + "name": "Alp: Zephyr Debug (J-Link)", + "type": "cortex-debug", + "servertype": "jlink", + "device": "", + }, + { + "name": "ALP: Zephyr Debug (J-Link)", + "type": "cortex-debug", + "servertype": "jlink", + "device": "AE822F4M55_HP", + }, + ], + })) + .unwrap(), + ) + .unwrap(); + + let mut g = global(&dir); + g.format = Format::Json; + let args = DebugConfigArgs { + core: None, + target_kind: Some("zephyr-mcu".to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: None, + preview: false, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + + let envelope: Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + let issues = envelope["issues"].as_array().unwrap(); + let found = issues + .iter() + .find(|i| i["code"] == "debug-config.legacy-entry-untouched") + .unwrap_or_else(|| panic!("no legacy-entry-untouched issue: {envelope}")); + assert_eq!(found["severity"], "info"); + assert!( + found["message"] + .as_str() + .unwrap() + .contains("ALP: Zephyr Debug (J-Link)"), + "{envelope}" + ); + // No migration happened -- the maintained entry merged ordinarily. + assert!( + issues + .iter() + .all(|i| i["code"] != "debug-config.legacy-entry-migrated"), + "{envelope}" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// tan-cli#170: `project.boardYaml` must report a resolvable `board.yaml` + /// instead of hardcoding `null` on a success — same resolver every other + /// command (`bootstrap`, `doctor`, `presets`, …) already uses. + #[test] + fn envelope_reports_the_projects_board_yaml_when_one_exists() { + let dir = tmp("board-yaml-reported"); + std::fs::write(dir.join("board.yaml"), "som:\n sku: E1M-AEN801\n").unwrap(); + + let mut g = global(&dir); + g.format = Format::Json; + let args = DebugConfigArgs { + core: None, + target_kind: Some("zephyr-mcu".to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: None, + preview: true, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + + let envelope: Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + let board_yaml = envelope["project"]["boardYaml"] + .as_str() + .unwrap_or_else(|| panic!("project.boardYaml must be populated: {envelope}")); + assert!( + board_yaml.ends_with("board.yaml"), + "expected a path ending in board.yaml, got {board_yaml}" + ); + // #170's own rationale, applied: `project.root` and `project.boardYaml` + // must not ship with different separators in the same object. + let root = envelope["project"]["root"].as_str().unwrap_or_default(); + assert_eq!( + board_yaml.contains('\\'), + root.contains('\\'), + "root and boardYaml disagree on separator: {envelope}" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// tan-cli#236, the pair of the test above: #170's fix routed this field + /// through the shared resolver, which builds `/board.yaml` + /// unconditionally — so without #236 it traded a hardcoded null for a path + /// to a file that need not exist. `debug-config` succeeds in a directory + /// with no `board.yaml` (the four golden previews all do), which makes it + /// the command where the wrong value is most reachable. + #[test] + fn envelope_reports_a_null_board_yaml_when_the_directory_has_none() { + let dir = tmp("board-yaml-absent"); + assert!(!dir.join("board.yaml").exists()); + + let mut g = global(&dir); + g.format = Format::Json; + let args = DebugConfigArgs { + core: None, + target_kind: Some("zephyr-mcu".to_string()), + server: Some("jlink".to_string()), + pre_launch_task: None, + svd: None, + preview: true, + }; + let run_result = run(&g, &args); + assert_eq!(run_result.exit, ExitCode::Success); + + let envelope: Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + assert!( + envelope["project"]["boardYaml"].is_null(), + "no board.yaml is there -- the field must not name one: {envelope}" + ); + // `root` is deliberately untouched: #236 rules it out of scope, and a + // run still legitimately reports where it stood. + assert!( + envelope["project"]["root"].is_string(), + "root must still report the resolved directory: {envelope}" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_svd_path_that_is_a_directory_is_refused() { + let dir = tmp("svd-is-a-dir"); + let not_a_file = dir.join("svd-dir"); + std::fs::create_dir_all(¬_a_file).unwrap(); + + let g = global(&dir); + let args = args_with_svd("zephyr-mcu", Some(¬_a_file.to_string_lossy()), true); + + assert_eq!(run(&g, &args).exit, ExitCode::InternalFailure); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_empty_svd_path_is_refused_rather_than_treated_as_absent() { + let dir = tmp("svd-empty"); + let g = global(&dir); + let args = args_with_svd("zephyr-mcu", Some(" "), true); + + assert_eq!(run(&g, &args).exit, ExitCode::InternalFailure); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn svd_on_a_target_kind_without_the_field_is_reported_not_silently_ignored() { + let dir = tmp("svd-non-mcu"); + let svd = dir.join("E8.svd"); + std::fs::write(&svd, "").unwrap(); + + let mut g = global(&dir); + g.format = Format::Json; + let mut args = args_with_svd("native-host", Some(&svd.to_string_lossy()), true); + args.server = None; + let run_result = run(&g, &args); + + assert_eq!(run_result.exit, ExitCode::Success); + let envelope: Value = + serde_json::from_str(&run_result.json.expect("json envelope")).unwrap(); + assert!( + envelope["data"]["configuration"].get("svdFile").is_none(), + "a native-host draft has no svdFile field to fill" + ); + let notes = envelope["data"]["notes"].as_array().unwrap(); + assert!( + notes + .iter() + .any(|n| n.as_str().unwrap_or_default().contains("--svd was given")), + "accepting --svd here and saying nothing is the silent no-op this note exists to \ + prevent: {notes:?}" + ); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/tan-cli/tests/contract.rs b/crates/tan-cli/tests/contract.rs index 73343de0..54ed7e43 100644 --- a/crates/tan-cli/tests/contract.rs +++ b/crates/tan-cli/tests/contract.rs @@ -419,6 +419,73 @@ fn code_lines(source: &str) -> String { .join("\n") } +/// The Python-side gate that owns the registry→source direction for every +/// registry entry whose emission site is a `python/` path (tan-cli#363), and +/// the test inside it that performs that check. +/// +/// Pinned by name so deleting or renaming the delegate cannot quietly leave +/// those entries owned by NEITHER gate. This is a text search like the one +/// #363 is about, but not the same defect: a Python `def` line cannot be +/// line-wrapped, so no reformat can break it — unlike a needle spanning a +/// call's arguments, which is what broke. +const PYTHON_EMISSION_GATE: &str = "python/tests/gates/test_frozen_issue_codes.py"; +const PYTHON_EMISSION_TEST: &str = "def test_every_python_side_registry_entry_is_still_emitted("; + +/// Shared by [`frozen_issue_codes`]'s `frozen` and `reserved` arms: check the +/// entry's declared emission site HERE when it is Rust source, or report it as +/// delegated when it is Python. Returns `true` when delegated. +/// +/// WHY the split. For a `crates/` entry, `literal` is a verbatim slice of Rust +/// source and `crates/` is frozen (it ships to nobody — the release assets are +/// PyInstaller freezes of `python/tan`, tan-cli#271), so the needle cannot rot +/// under a reformat. For a `python/` entry it is not a needle at all: it is a +/// prose DESCRIPTION of the emission shape (`Issue("sdk.network-required", +/// "warning", ...)`, `f"support-bundle.{c.name}" where c.name == "boardYaml"`). +/// Matching that against source keys the gate on ONE single-line formatting of +/// a call rather than on Python syntax, so a line wrap turns a live registered +/// code into a stale-code verdict — which is exactly what happened to +/// `sdk.network-required` on Linux, Windows AND macOS at once (tan-cli#363). +/// +/// It was never one stale row. MEASURED while fixing #363: 42 of the 198 +/// python-side entries failed this same substring check; only the first was +/// ever reported, because `assert!` panics. Fixing the one row the issue named +/// would have surfaced the next 41 immediately. +/// +/// This test cannot import Python's `ast`, and a Python parser hand-rolled in +/// Rust would be a weaker copy of one that already exists +/// (`python/tests/gates/`), so python-side entries are delegated to +/// [`PYTHON_EMISSION_GATE`], which parses them. What stays enforced here: the +/// delegation is TOTAL (`frozen_issue_codes` rejects any `emittedBy` under +/// neither root, and the Python gate asserts the same mirror), the delegated +/// file still exists, and both halves are non-empty. +fn check_or_delegate(code: &str, rel: &str, literal: &str, status: &str, remedy: &str) -> bool { + let path = repo_root().join(rel); + if rel.starts_with("python/") { + assert!( + path.is_file(), + "{code}: `emittedBy` names {rel}, which does not exist. The emission site \ + moved or was deleted — update contract/issue-codes.json to name the real \ + one, so {PYTHON_EMISSION_GATE} can check it." + ); + return true; + } + assert!( + rel.starts_with("crates/"), + "{code}: `emittedBy` is {rel:?}, under neither `crates/` (checked here) nor \ + `python/` (checked by {PYTHON_EMISSION_GATE}) — it would be gated by nothing. \ + Point it at the real emission site." + ); + let source = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{code}: cannot read {rel}: {e}")); + let status_upper = status.to_uppercase(); + assert!( + code_lines(&source).contains(literal), + "{status_upper} ISSUE CODE `{code}` is gone: {rel} no longer contains \ + {literal:?} outside comments.\n{remedy}" + ); + false +} + /// The frozen `issues[].code` strings alp-sdk-vscode matches with `===` /// (tan-cli#106), gated against `contract/issue-codes.json`. /// @@ -437,6 +504,10 @@ fn code_lines(source: &str) -> String { /// the whole refusal branch and leaves the string behind passes here. /// `crates/tan-cli/src/commands/bootstrap/mod.rs`'s own unit tests cover the /// emission; this covers the spelling. +/// +/// SCOPE, since tan-cli#363: `crates/` entries only. See +/// [`check_or_delegate`] for why a `python/` entry cannot be checked by a +/// substring needle and who checks it instead. #[test] fn frozen_issue_codes() { let registry_path = contract_root().join("issue-codes.json"); @@ -465,6 +536,13 @@ fn frozen_issue_codes() { sources.len() ); + // Both halves of the tan-cli#363 split, counted so neither can silently + // empty out: `checked_here` is the `crates/` entries this test still + // substring-checks, `delegated` the `python/` ones it hands to + // `PYTHON_EMISSION_GATE`. See `check_or_delegate`. + let mut checked_here = 0usize; + let mut delegated = 0usize; + for entry in codes { let code = entry["code"].as_str().expect("issueCodes[].code"); let status = entry["status"].as_str().expect("issueCodes[].status"); @@ -476,20 +554,22 @@ fn frozen_issue_codes() { let literal = entry["literal"] .as_str() .unwrap_or_else(|| panic!("{code}: a frozen code needs `literal`")); - let path = repo_root().join(rel); - let source = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("{code}: cannot read {rel}: {e}")); - assert!( - code_lines(&source).contains(literal), - "FROZEN ISSUE CODE `{code}` is gone: {rel} no longer contains \ - {literal:?} outside comments.\n\ - alp-sdk-vscode matches this code with `===` and that match FAILS \ + if check_or_delegate( + code, + rel, + literal, + status, + "alp-sdk-vscode matches this code with `===` and that match FAILS \ OPEN — the extension will not error, log or warn, it will silently \ skip the check. If this rename is deliberate: bump the CLI \ MAJOR/MINOR, update contract/issue-codes.json + CHANGELOG.md, and \ open the matching alp-sdk-vscode issue. Do NOT loosen the consumer \ - to a prefix match." - ); + to a prefix match.", + ) { + delegated += 1; + } else { + checked_here += 1; + } } "reserved" => { // Pre-consumer: the spelling exists at the emission site (kept @@ -510,17 +590,19 @@ fn frozen_issue_codes() { let literal = entry["literal"] .as_str() .unwrap_or_else(|| panic!("{code}: a reserved code needs `literal`")); - let path = repo_root().join(rel); - let source = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("{code}: cannot read {rel}: {e}")); - assert!( - code_lines(&source).contains(literal), - "RESERVED ISSUE CODE `{code}` is gone: {rel} no longer contains \ - {literal:?} outside comments. No consumer matches it yet, so \ - dropping or renaming it is not a breaking wire change -- but the \ - registry entry is now stale. Update contract/issue-codes.json to \ - match, or restore the emission." - ); + if check_or_delegate( + code, + rel, + literal, + status, + "No consumer matches it yet, so dropping or renaming it is not a \ + breaking wire change -- but the registry entry is now stale. Update \ + contract/issue-codes.json to match, or restore the emission.", + ) { + delegated += 1; + } else { + checked_here += 1; + } } "retired" => { // A retired code is not emitted any more, but the consumer branch @@ -545,6 +627,38 @@ fn frozen_issue_codes() { other => panic!("{code}: unknown status {other:?} (expected frozen|reserved|retired)"), } } + + // Non-vacuity, both halves (tan-cli#275's standing lesson: an assertion + // nobody has watched fail is not proven to fire — and after #363 there are + // two ways for this one to stop checking anything). If either half empties + // out, a registry-wide `emittedBy` convention change has quietly silenced + // this gate or the Python one, and the count is the only thing that says so. + assert!( + checked_here > 0 && delegated > 0, + "expected BOTH halves of the issue-code registry to be non-empty, got \ + {checked_here} checked here (crates/) and {delegated} delegated to \ + {PYTHON_EMISSION_GATE} (python/) — one side is no longer being checked \ + by anything" + ); + + // The delegate itself: pinned by name, so removing the Python check cannot + // leave the delegated entries owned by neither gate. `frozen_issue_codes` + // and that test assert the same partition from opposite sides, so an + // `emittedBy` repointed at a third kind of path reddens both. + let gate_path = repo_root().join(PYTHON_EMISSION_GATE); + let gate_source = std::fs::read_to_string(&gate_path).unwrap_or_else(|e| { + panic!( + "{PYTHON_EMISSION_GATE} is unreadable ({e}), but {delegated} registry \ + entries are delegated to it — restore it, or bring their check back here" + ) + }); + assert!( + gate_source.contains(PYTHON_EMISSION_TEST), + "{PYTHON_EMISSION_GATE} no longer defines `{PYTHON_EMISSION_TEST}...`, and \ + {delegated} registry entries are delegated to it (tan-cli#363) — they would \ + now be gated by nothing. Restore that test, or repoint this pin at whatever \ + replaced it." + ); } /// The OTHER direction, and the one that did not exist (tan-cli#219). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b13a2d0e..8904c5a9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -105,11 +105,15 @@ Gated on Target 1 green on silicon. `SUPPORTED_CLI_VERSION` moves; the Python `tan` becomes what customers get. Gated on the RC having soaked, not on a date. -### tan — `v0.6.0` · full command-surface parity - -The verbs deliberately left out of the RC: `model`, `new-som`, `monitor`, -`faultdecode`, the introspection set, `renode`, and the seven entirely-unported -commands. Also the known oracle divergences filed during the port. +### tan — `v0.6.0` · known oracle divergences + +The full command surface landed inside the `v0.5.0` RC cycle instead of +waiting for this milestone: the seven verbs that shipped as stubs at rc1 +(`scaffold`, `completion`, `diff`, `pinmux`, `inspect`, `trace`, +`support-bundle` — tan-cli#260, #257), `model` (#253), `new-som` (#254), +`monitor` (#255), `faultdecode` (#256), and `renode --sim-mode` (#77) are all +real by `v0.5.0-rc4`. What is still deferred to `v0.6.0` is narrower — the +known oracle divergences filed during the port (see the `deferred` label). Deferred is not a bug backlog — the `deferred` label means *chosen*, and each issue records what the oracle does so the choice can be re-read later. diff --git a/docs/release-contract.md b/docs/release-contract.md index aa40396e..ce3f544c 100644 --- a/docs/release-contract.md +++ b/docs/release-contract.md @@ -7,12 +7,22 @@ release assets. The **alp-sdk-vscode** extension downloads the matching asset on activation, so the tag scheme and asset names are a **stable contract** — change them only in lockstep with the extension's `releaseAssetForTarget`. -> **From v0.5.0 the assets are PyInstaller `--onefile` freezes of `python/`** -> (the Python port), not `cargo` builds of `crates/` — tan-cli#271. The asset -> NAMES keep the Rust target triples, because the extension hardcodes them. -> Four assets ship, not eight, and the crates.io publish is gone. Everything +> **From v0.5.0 the assets are PyInstaller freezes of `python/`** (the Python +> port), not `cargo` builds of `crates/` — tan-cli#271. The asset NAMES keep +> the Rust target triples, because the extension hardcodes them. Four assets +> ship, not eight, and the crates.io publish is gone. **From v0.5.0 each asset +> is also an ARCHIVE (`.zip` / `.tar.gz`) of a PyInstaller `--onedir` freeze, +> not a raw binary** — tan-cli#349, see "Asset names" below for why. Everything > below is written for that release; where it describes the retired Rust > pipeline it says so explicitly. +> +> **v0.5.0 is the transition tag, and it is not cut yet.** Every tag published +> so far ships a RAW binary — `v0.4.1` (currently `latest`) and `v0.5.0-rc4` +> included. rc4 carries the `--onefile` freeze as a raw asset, which is what the +> 13–19 s macOS measurement below was taken on; do not read "`--onedir`" or +> "archive" as something rc4 shipped, because it shipped neither. Both +> installers consequently support **both** shapes and decide per release +> (tan-cli#356) — see [Which shape a release publishes](#which-shape-a-release-publishes). ## Tag scheme @@ -80,13 +90,31 @@ other. ## Asset names -One **raw, uncompressed binary per target triple** (no `.zip` / `.tar.gz`): +**From v0.5.0 (tan-cli#349), one ARCHIVE per target triple** — up to and +including `v0.5.0-rc4` it is one raw, uncompressed binary: ``` -tan- # Unix (no extension) -tan-.exe # Windows +tan-.tar.gz # Unix, v0.5.0 and later +tan-.zip # Windows, v0.5.0 and later +tan- # Unix, v0.5.0-rc4 and earlier +tan-.exe # Windows, v0.5.0-rc4 and earlier ``` +Why an archive now: the assets are PyInstaller `--onedir` freezes, not +`--onefile`. `--onefile` re-extracts its whole ~14 MB runtime into a fresh temp +dir on EVERY invocation — measured 13–19 s for `--version` on the published +v0.5.0-rc4 macOS `--onefile` asset (unsigned re-extracted `.dylib`s get +re-verified by the OS on every load), which **exceeds alp-sdk-vscode's own 3 s +version-probe budget** (`vscodeAdapter.ts:1406`) — that asset's `--version` +TIMED OUT under the extension's own probe, not merely "slow". `--onedir` +extracts once, at install time, instead of once per invocation: measured +0.337 s mean vs 0.880 s mean for `--version` on this same host. The archive is +the one-file-per-target shape that lets `checksums.txt` / the provenance +attestation / `install.sh` / `install.ps1` keep dealing with a single thing per +target even though the payload is now a directory (`tan` + `_internal/`), not +a single file — both installers unpack it and install a thin launcher rather +than the executable itself. + Download URL is fully deterministic: ``` @@ -100,18 +128,56 @@ Plus two non-binary assets, carrying the same build-provenance attestation: | `checksums.txt` | sha256 of every other asset. | | `envelope-contract.json` | The JSON envelope contract — the frozen issue codes (`contract/issue-codes.json`) plus one golden envelope per command family (`contract/envelopes/`), so a consumer's contract test diffs against a published artefact instead of a hand-copied fixture that drifts. See [`contract/README.md`](../contract/README.md). | +## Which shape a release publishes + +Two shapes exist in the wild and both are supported for as long as the raw tags +are installable, so **a consumer must not assume either one**. tan-cli#356 is +what that costs when you do: #349 pointed both installers at the archive names +unconditionally, and since no published tag has them, `sh install.sh` — +the documented command, with no arguments — 404'd on +`tan-x86_64-unknown-linux-gnu.tar.gz` at `v0.4.1`. + +`checksums.txt` is the answer. It lists **every** asset in the release, it is +published at every tag, and it is the integrity source a consumer has to fetch +anyway — so it doubles as the asset manifest at zero extra cost. Both +installers now fetch it FIRST, take `tan-.tar.gz` / `.zip` if it is +listed there and the raw `tan-` / `.exe` if it is not, and then verify +the download against the digest out of that same file **before** unpacking +anything or writing to the install directory. + +Deliberately **not** a version comparison against `v0.5.0`. That is a second +source of truth about the release, kept somewhere the release cannot update, +and it has to model SemVer pre-release ordering correctly (`v0.5.0-rc4` sorts +BELOW `v0.5.0`) in POSIX `sh` and in PowerShell, in agreement, forever. It is +also **not** a magic-number sniff of the downloaded bytes, even though that is +#349's own rule on the alp-sdk-vscode side: the extension holds a file it has +already fetched, whereas an installer has to choose a NAME before there are any +bytes to sniff. + +An installer that finds neither name refuses and says so, naming both — with +`checksums.txt` doubling as the manifest, "this platform has no asset in this +release" and "the release shipped an asset and forgot to check-sum it" arrive +through the same door, and only the release page can tell them apart. + ## Targets published **Four** assets, one per build runner: -| VS Code `process.platform` | `process.arch` | Target triple | Asset name | Built on | -| -------------------------- | -------------- | --------------------------- | -------------------------------- | --------------- | -| `win32` | `x64` | `x86_64-pc-windows-msvc` | `tan-x86_64-pc-windows-msvc.exe` | `windows-latest` | -| `darwin` | `x64` | `x86_64-apple-darwin` | `tan-x86_64-apple-darwin` | `macos-15-intel` | -| `darwin` | `arm64` | `aarch64-apple-darwin` | `tan-aarch64-apple-darwin` | `macos-15` | -| `linux` | `x64` | `x86_64-unknown-linux-gnu` | `tan-x86_64-unknown-linux-gnu` | `ubuntu-latest` + `python:3.12-slim-bullseye` | - -After download on a Unix host the consumer must `chmod +x` the raw binary. +| VS Code `process.platform` | `process.arch` | Target triple | Asset name | Built on | +| -------------------------- | -------------- | --------------------------- | ------------------------------------- | --------------- | +| `win32` | `x64` | `x86_64-pc-windows-msvc` | `tan-x86_64-pc-windows-msvc.zip` | `windows-latest` | +| `darwin` | `x64` | `x86_64-apple-darwin` | `tan-x86_64-apple-darwin.tar.gz` | `macos-15-intel` | +| `darwin` | `arm64` | `aarch64-apple-darwin` | `tan-aarch64-apple-darwin.tar.gz` | `macos-15` | +| `linux` | `x64` | `x86_64-unknown-linux-gnu` | `tan-x86_64-unknown-linux-gnu.tar.gz` | `ubuntu-latest` + `python:3.12-slim-bullseye` | + +Each archive's one top-level entry is `tan/`, containing `tan` (`tan.exe` on +Windows) plus `_internal/` (its runtime) — `install.sh` / `install.ps1` unpack +it to a private `tan-cli-lib/` directory and install a thin launcher script +alongside it rather than the executable itself. A consumer not using either +installer must unpack the archive themselves and (on Unix) `chmod +x` the +`tan` executable inside it — the archive does not require this itself +(`tar`/`zip` both preserve the executable bit that `build_binary.sh` sets), +but it is cheap insurance the installers also apply unconditionally. ### Not published (accepted 404) @@ -139,6 +205,21 @@ it runs **only on musl distros** — it is not the "static, runs on any libc" artefact the Rust `-musl` target produced, and shipping it under that name would break every Ubuntu/Debian/Fedora consumer. +**`install.sh` refuses a musl host (e.g. Alpine) outright, for every `--version` +— including an older tag that genuinely still publishes a `-unknown-linux-musl` +asset**, such as `v0.4.1`. This is a deliberate, host-level refusal, not a +per-tag one: the raw-vs-archive shape selection is genuinely per-tag +(tan-cli#356), but musl support is not resurrected for any tag by this +installer, past or future, regardless of what that tag's own `checksums.txt` +lists. The alternative — working on Alpine for old tags and refusing for new +ones, with the boundary being whichever `--version` a user happened to type — +is a worse promise than one clear refusal every time. A musl consumer installs +from a checkout instead (`git clone` + `pip install ./tan-cli/python`), which +the refusal message says. Do not read this refusal as fixed by #356: it +predates that change and is orthogonal to it, and moving it to run per-tag +(after the `checksums.txt` lookup) is a valid future revisit, not a limitation +inherent to musl or to PyInstaller. + ### Reference `releaseAssetForTarget` (vscode side) This is the extension's map **as it stands today**, kept here so the mismatch is @@ -180,7 +261,7 @@ over the payload**, and how it is measured matters: | Where you look | What you get | Useful? | | --- | --- | --- | -| `readelf -V` on the shipped onefile | `GLIBC_2.14`, under every image | **No.** That is PyInstaller's vendored bootloader. It is a container-INVARIANT constant — measured identical from bullseye (real floor 2.30) and trixie (real floor 2.38) — so it cannot detect the build image regressing to a newer glibc, which is the only thing the measurement is for. Lower bound only. | +| `readelf -V` on the onedir executable | `GLIBC_2.14`, under every image | **No.** That is PyInstaller's vendored bootloader. It is a container-INVARIANT constant — measured identical from bullseye (real floor 2.30) and trixie (real floor 2.38) — so it cannot detect the build image regressing to a newer glibc, which is the only thing the measurement is for. Lower bound only. | | the appended payload | the real floor | **Yes.** libpython + the extension modules + their `.so` dependencies, enumerated from `.build/tan/PKG-00.toc` (a plain Python literal listing everything PyInstaller appended) and read with `pyelftools`. | The build step refuses to emit a number if the scan finds implausibly few @@ -197,7 +278,7 @@ phenomenon is real, both numbers in it are wrong (alp-sdk-vscode#370). ## Build provenance -Every release asset (all four `tan-*` binaries plus `checksums.txt` and +Every release asset (all four `tan-*` archives plus `checksums.txt` and `envelope-contract.json` — the step's `subject-path` is `assets/*`) carries a GitHub **build-provenance attestation**, generated by `actions/attest-build-provenance` in the `release` job. Verify a downloaded @@ -211,16 +292,32 @@ gh attestation verify --repo alplabai/tan-cli \ `--repo` alone binds the artefact to *some* workflow in this repository; `--signer-workflow` is what pins it to the release job specifically. -`checksums.txt` (sha256 of every binary) is itself a release asset and is +`checksums.txt` (sha256 of every archive) is itself a release asset and is covered by the same attestation. The `release` job is the only job with `id-token: write` / `attestations: write` — every other job keeps the workflow-level `contents: write` (or, for `gates`, `contents: read`). ## Decisions -- **Raw binary, not an archive.** The stripped release `tan` is small; a raw - asset means the downloader fetches one file and (on Unix) `chmod +x`s it — no - unzip step, no archive-layout assumption. +- **Archive, not a raw binary (tan-cli#349, from v0.5.0).** The build + switched from PyInstaller `--onefile` to `--onedir`, so each release asset + is now a `.zip`/`.tar.gz` archive of a directory (`tan` + `_internal/`), not + a single raw executable. **Why**: `--onefile` re-extracts its whole ~14 MB + runtime into a fresh temp dir on EVERY invocation — measured 13–19 s for + `--version` on the published v0.5.0-rc4 macOS asset (unsigned re-extracted + `.dylib`s get re-verified by the OS on every load) — which blew past + alp-sdk-vscode's own 3 s version-probe budget (`vscodeAdapter.ts:1406`): that + asset's `--version` TIMED OUT under the extension's own probe, not merely + "slow". `--onedir` extracts once, at install time, instead of once per + invocation — measured 0.337 s mean vs 0.880 s mean for `--version` on the + same host, a >2x win even on Windows, which was never the platform in + trouble. This is a real behavioural cost, not a preference, so raw-binary + stays retired for NEW tags even though it was simpler for a consumer to + fetch: `install.sh` / `install.ps1` absorb the extra unpack step so most + consumers never see it. Retired for new tags is not the same as gone: the raw + assets already published stay published and stay installable, which is why + both installers keep a working path for them and pick per release rather than + per version number (tan-cli#356). - **Four targets, one per runner.** A PyInstaller freeze embeds the interpreter it ran under, so there is no cross-build to be had: the runner IS the target. Eight targets were possible while the binary was a `cargo` build (Windows @@ -235,7 +332,7 @@ workflow-level `contents: write` (or, for `gates`, `contents: read`). - **Race-free publish.** Matrix jobs upload artifacts; a single `release` job collates and creates the release, so parallel jobs never race on release creation. -- **The GitHub release needs no secrets** — binaries, `checksums.txt`, +- **The GitHub release needs no secrets** — archives, `checksums.txt`, `envelope-contract.json` and the provenance attestation all run on the default `GITHUB_TOKEN`. **No registry publish runs at all any more**, so neither `CARGO_REGISTRY_TOKEN` nor `NPM_TOKEN` is on the release path: diff --git a/docs/setools.md b/docs/setools.md new file mode 100644 index 00000000..66a3fea7 --- /dev/null +++ b/docs/setools.md @@ -0,0 +1,109 @@ + +# SETOOLS — signing an Alif Ensemble slot0 ATOC for Flow D + +`tan flash`'s `alif_mram_jlink` backend ("Flow D": J-Link straight over SWD, +no SE-UART) burns a **signed ATOC** into an Alif Ensemble part's on-die MRAM. +Producing that signature is Alif's own job, done by the Alif Security Toolkit +(SETOOLS) `app-gen-toc` step — `tan` does not sign anything itself; it drives +`app-gen-toc` for you when it can find it, and refuses loudly, naming exactly +what it tried, when it cannot (tan-cli#365). + +## SETOOLS is not part of tan, and never will be + +SETOOLS is **license-gated and obtained directly from Alif**. Neither `tan` +nor alp-sdk redistributes it. Get it from the Alif developer portal under +your own Alif account, then point `tan` at the directory you installed it +into — the sections below cover how. + +Two shapes matter, depending on host OS: + +- **Linux bundle**: `app-release-exec-linux-SE_FW_x.y.z` — the one + executable `tan` looks for inside it is `app-gen-toc` (`west flash`'s own + `alif_flash` runner, for the SE-UART path, looks for `app-write-mram` + separately; Flow D here never does). Running `app-gen-toc` writes + `app-package-map.txt`, its own build **report** — not another executable, + and not something `tan` searches for the way it searches for the tool. +- **Windows**: a genuine Windows SETOOLS install ships `app-gen-toc.exe` + instead of the bare Linux name; `tan` looks for both. + +## Pointing `tan flash` at your install: three sources, one precedence order + +`tan flash` accepts three ways to say where SETOOLS lives. **Highest +precedence wins outright** — a lower source is never consulted once a higher +one resolves: + +1. **`--setools-dir `** — a flag on `tan flash` itself. The one durable, + discoverable-from-`--help` way to pin this per invocation, regardless of + shell session or manifest state. +2. **`SETOOLS_DIR=`** — an environment variable. Survives across + `tan build` runs (unlike the manifest field below), but is scoped to + whatever shell/session set it. +3. **`flash_args.setools_dir`** in `build/system-manifest.yaml` — lowest + precedence, and **not durable**: `tan build` regenerates this file on + every run (`python/tan/commands/build/manifest.py`), and alp-sdk's own + emit carries no `setools_dir` key at all. A hand-edit here is silently + overwritten by your next build. Prefer the flag or the environment + variable for anything you want to survive a rebuild; treat this field as + build-owned, not a place to hand-author a durable setting. + +If none of the three resolves, `tan flash` refuses with a message naming all +three sources, in this same order, and how to set each one — it never +searches the filesystem for a plausible SETOOLS install: a *wrong* SETOOLS +silently signing against the wrong part is worse than `tan` refusing outright. + +## What `tan` actually does with it + +When a Flow D entry has no `atoc`/`atoc_address` yet (an AEN801 slot0 slice's +manifest today typically carries only `jlink_flash_device` and +`slot0_load_address` — alp-sdk's emit does not sign anything itself), +`tan flash` drives one `app-gen-toc` sign step for you: + +1. copies the build's raw `.bin` into `/build/images/`; +2. writes an app-only ATOC config to `/build/config/` — no + `"DEVICE"` key: the on-module factory device config is already correct for + your part, and this step must not overwrite it; +3. runs `app-gen-toc`, inside `SETOOLS_DIR`, against that config; +4. reads the resulting ATOC's MRAM placement back out of + `/build/app-package-map.txt`. This file is **APPEND-mode** — + the accumulated sign record for the whole install, including hand-runs + you did outside `tan` — so `tan` never truncates or deletes it + (tan-cli#373): it records the file's size and mtime beforehand and + refuses if either is unchanged after a zero exit (a soft failure that + would otherwise read back a stale, unrelated address as if it were + fresh), and separately confirms `/build/AppTocPackage.bin` + (which — unlike the map — IS overwritten whole every run, so there is no + history in it to protect) was actually rewritten before trusting either. + +A successful sign names which SETOOLS install did it (`--setools-dir`, +`SETOOLS_DIR`, or `flash_args.setools_dir` — see `setools.source` in `tan +flash`'s own output), not only a failed one. + +Under `--dry-run` none of this touches your SETOOLS install or spawns +`app-gen-toc` at all — `tan flash --dry-run` prints what it *would* sign and +stops there. + +If you already resolved a signature yourself — an explicit `flash_args.atoc` ++ `flash_args.atoc_address`, or `flash_args.atoc_map` pointing at your own +`app-package-map.txt` — none of the above runs; `tan` uses what you gave it +verbatim. + +## Two probes, one cloned serial: why `jlink_serial` is not always enough + +On a bench carrying more than one J-Link, `flash_args.jlink_serial` picks a +probe by serial only — `JLinkExe` has no USB-port selector. Some OEM J-Link +probes ship with a **cloned serial number shared across more than one +physical unit**, in which case `jlink_serial` alone cannot tell two probes +apart, even when set: a wrong-board write is now possible even with a serial +pinned. `flash_args.expect_dpidr` (paired with `flash_args.jlink_device`) is +the real per-silicon discriminator for this case — `tan` reads it back on +connect, before ever writing MRAM, and refuses when it doesn't match. Set +both when your bench has more than one probe, or when a shared/cloned serial +is a possibility; do not rely on `jlink_serial` alone to disambiguate. + +## Related + +- `docs/adr/` — architecture decisions this backend follows (no new hardware + fact invented in `tan`; every identifier above comes from `flash_args`, + which alp-sdk's `metadata/**` populates). +- tan-cli#353, #365, #366, #367, #368, #369, #373 — the issues this doc and + the surrounding fixes answer. diff --git a/install.ps1 b/install.ps1 index 723dd5ec..ee8e43f7 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,22 +1,51 @@ # SPDX-License-Identifier: Apache-2.0 # -# tan installer for Windows. Downloads the prebuilt tan.exe for this platform -# from GitHub Releases and installs it. By DEFAULT it installs under -# %LOCALAPPDATA%\Programs\tan and updates the USER Path, so NO admin is needed. -# Pass -System to install under %ProgramFiles% and update the MACHINE Path -# (that requires an elevated / "Run as administrator" PowerShell). +# tan installer for Windows. Downloads the prebuilt tan release archive for +# this platform from GitHub Releases, expands it, and installs a launcher. By +# DEFAULT it installs under %LOCALAPPDATA%\Programs\tan and updates the USER +# Path, so NO admin is needed. Pass -System to install under %ProgramFiles% +# and update the MACHINE Path (that requires an elevated / "Run as +# administrator" PowerShell). +# +# The asset's SHAPE depends on which release you install, and this script reads +# that off the release rather than assuming it (tan-cli#356): +# +# * From v0.5.0 (tan-cli#349) the asset is a PyInstaller --onedir freeze +# archived as a .zip, not a raw tan.exe: --onefile re-extracted its whole +# runtime into a fresh temp dir on EVERY invocation, which measured 13-19 s +# on macOS (unsigned re-extracted .dylibs get re-verified by the OS on every +# load) and even on Windows measured >2x slower per-invocation than +# --onedir. $Dir\tan.cmd is then a thin launcher, not the executable itself +# -- the unpacked freeze lives in $Dir\tan-cli-lib\. +# * Every tag published BEFORE v0.5.0 -- including v0.4.1, which is what +# `latest` resolves to today, and the v0.5.0-rc4 pre-release -- publishes a +# raw tan.exe. That one is installed as $Dir\tan.exe, no launcher and no +# tan-cli-lib\. +# +# Mirrors install.sh's shape for the .tar.gz side of the same change. # # irm https://raw.githubusercontent.com/alplabai/tan-cli/main/install.ps1 | iex -# .\install.ps1 [-Version vX.Y.Z] [-Dir ] [-System] +# .\install.ps1 [-Version vX.Y.Z] [-Dir ] [-System] [-NoModifyPath] [CmdletBinding()] param( [string]$Version = "latest", [string]$Dir = "", - [switch]$System + [switch]$System, + # Skip the Path update. install.sh has had --no-modify-path since it started + # editing rc files; this script writes to the USER (or MACHINE) environment + # in the registry, which is the more persistent of the two, and had no way + # to opt out at all. Also what lets this script's own tests run without + # leaving a pile of dead temp directories on the developer's Path. + [switch]$NoModifyPath ) $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest $repo = "alplabai/tan-cli" +# Where release assets are fetched from. Overridable for an internal mirror that +# carries the same / layout (and it is how this installer's own tests +# serve a fixture release offline). `latest` is still resolved against GitHub's +# API -- a mirror hosts bytes, it does not decide which tag is current. +$baseUrl = if ($env:TAN_INSTALL_BASE_URL) { $env:TAN_INSTALL_BASE_URL } else { "https://github.com/$repo/releases/download" } # host arch -> rust target arch part $archRaw = $env:PROCESSOR_ARCHITECTURE @@ -26,7 +55,8 @@ switch ($archRaw) { "ARM64" { $archPart = "aarch64" } default { throw "install.ps1: unsupported architecture '$archRaw'" } } -$asset = "tan-$archPart-pc-windows-msvc.exe" +$archiveAsset = "tan-$archPart-pc-windows-msvc.zip" +$rawAsset = "tan-$archPart-pc-windows-msvc.exe" # install dir + PATH scope: user-local (no admin) by default, machine with -System (admin) if ($System) { @@ -46,8 +76,10 @@ if ($System) { # front and build both URLs from it. # # The digest for a given filename really does move between tags: at v0.4.0-rc1 -# tan-x86_64-pc-windows-msvc.exe is f159c1dc..., at v0.4.0 it is a80fb5da..., same -# asset name. Anything that caches or hardcodes a digest is wrong by construction. +# tan-x86_64-pc-windows-msvc.exe was f159c1dc..., at v0.4.0 it was a80fb5da..., +# same asset name (pre-v0.5.0-rc4, when the asset was a raw .exe rather than +# today's .zip -- the property holds identically for the archive). Anything +# that caches or hardcodes a digest is wrong by construction. # Resolved through the API's `tag_name` rather than by inspecting the # /releases/latest redirect, which install.sh uses. Not gratuitous divergence -- # each host gets the mechanism that is actually robust on it: @@ -85,78 +117,140 @@ if ($Version -eq "latest") { exit 1 } } -$url = "https://github.com/$repo/releases/download/$Version/$asset" -$sumsUrl = "https://github.com/$repo/releases/download/$Version/checksums.txt" +$sumsUrl = "$baseUrl/$Version/checksums.txt" New-Item -ItemType Directory -Force -Path $Dir | Out-Null -$dest = Join-Path $Dir "tan.exe" +$LibDir = Join-Path $Dir "tan-cli-lib" -# Download to a TEMP file, never straight to $dest. Writing to the destination -# first and checking afterwards means a mismatched binary has already landed -- +# Download to TEMP, never straight into $Dir. Writing into the destination +# first and checking afterwards means a mismatched file has already landed -- # and on Windows it may already be locked, or already on PATH, by the time the -# check fails. Verify, then move. -$tmp = Join-Path ([IO.Path]::GetTempPath()) ("tan-" + [Guid]::NewGuid().ToString("N") + ".exe") -$sumsTmp = "$tmp.checksums.txt" -Write-Host "install.ps1: downloading tan ($archPart, $Version)..." +# check fails. Verify, then unpack/move. +# +# One GUID stem for all three temp paths, so `finally` can clear them with a +# single wildcard even though $tmp's extension is not known until the asset has +# been chosen (Expand-Archive REFUSES a path that does not end in .zip, so the +# extension cannot just be left off). +$tmpBase = Join-Path ([IO.Path]::GetTempPath()) ("tan-" + [Guid]::NewGuid().ToString("N")) +$sumsTmp = "$tmpBase.checksums.txt" +$stage = "$tmpBase.stage" try { - # The transport error a 404 throws here says only THAT the fetch failed, - # never why -- and a 404 for an asset that was never published looks - # identical to a network/proxy outage otherwise. Name the one cause this - # script can actually know (there is no Windows arm64 asset, ever, from - # v0.5.0 -- a PyInstaller freeze cannot be cross-compiled, and this release - # builds on four runners, not six) and point at the source install; guess - # at nothing else. Mirrors install.sh's equivalent case for linux/arm64. - try { - Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing - } catch { - Write-Host "install.ps1: download failed: $url" -ForegroundColor Red - if ($archPart -eq "aarch64") { - Write-Error "install.ps1: there is no prebuilt Windows arm64 asset from v0.5.0 onward. The binary is a frozen build that must be produced on the architecture it runs on, and the release builds no Windows arm64 leg. Install from a checkout instead: git clone https://github.com/$repo && pip install ./tan-cli/python" - } else { - Write-Error "install.ps1: if this is a 404 rather than a network failure, check which assets $Version actually publishes: https://github.com/$repo/releases" - } - exit 1 - } - # ----------------------------------------------------------------------- - # Verify what landed against the checksums.txt published in the SAME release. + # Verify what lands against the checksums.txt published in the SAME release. # # TLS says we talked to github.com. It does not say github.com handed us the # bytes we published, and it says nothing about a proxy, a cache, or a - # truncated write. checksums.txt already exists at every tag, and - # alp-sdk-vscode already verifies its own managed download against it - # (alplabai/alp-sdk-vscode#389) and refuses a mismatch. Until this landed the - # two acquisition paths for the same binary disagreed about whether they - # check it -- and the unverified one is what the extension's "Install tan CLI - # (global)" button runs, whose result the extension's resolver then PREFERS - # over its own verified copy, on every activation, indefinitely. + # truncated write. checksums.txt already exists at every tag and now covers + # the ARCHIVES rather than raw binaries, and alp-sdk-vscode already verifies + # its own managed download against it (alplabai/alp-sdk-vscode#389) and + # refuses a mismatch. Until this landed the two acquisition paths for the + # same binary disagreed about whether they check it -- and the unverified + # one is what the extension's "Install tan CLI (global)" button runs, whose + # result the extension's resolver then PREFERS over its own verified copy, + # on every activation, indefinitely. # # THREE distinct outcomes, three distinct messages, all refusing. Being - # offline behind a corporate proxy and being handed a tampered binary are not - # the same situation and must not read the same. (Get-FileHash is built in - # since PowerShell 4, so the POSIX script's fourth outcome -- no sha256 tool - # on PATH -- cannot arise here.) Nothing reaches $dest on any of them. + # offline behind a corporate proxy and being handed a tampered archive are + # not the same situation and must not read the same. (Get-FileHash is built + # in since PowerShell 4, so the POSIX script's fourth outcome -- no sha256 + # tool on PATH -- cannot arise here.) Nothing reaches $Dir on any of them. + # + # checksums.txt is fetched FIRST, before the asset, because it is now also + # the asset MANIFEST -- see the selection block below. # ----------------------------------------------------------------------- - Write-Host "install.ps1: verifying against $Version checksums.txt..." + Write-Host "install.ps1: fetching $Version checksums.txt..." try { Invoke-WebRequest -Uri $sumsUrl -OutFile $sumsTmp -UseBasicParsing } catch { # Outcome 1: the digests could not be fetched. Says nothing about the - # binary -- which is why it must not be worded like a mismatch. - Write-Error "install.ps1: could not fetch $sumsUrl`nRefusing to install -- the binary downloaded, but there is nothing to check it against. This is a fetch failure, NOT evidence the binary is bad. Retry, or check a proxy/firewall." + # release's contents -- which is why it must not be worded like a + # mismatch. + Write-Error "install.ps1: could not fetch $sumsUrl`nRefusing to install -- that file is both the list of assets $Version publishes and the only thing to verify a download against, so without it there is nothing to fetch and nothing to check. This is a fetch failure, NOT evidence anything is wrong with the release. Retry, or check a proxy/firewall." exit 1 } - $want = $null - foreach ($line in Get-Content -LiteralPath $sumsTmp) { - $parts = $line -split '\s+', 2 - if ($parts.Count -eq 2 -and $parts[1].Trim() -eq $asset) { $want = $parts[0].Trim(); break } + # ----------------------------------------------------------------------- + # WHICH SHAPE does this release publish? (tan-cli#356) + # + # From v0.5.0 the asset is a .zip of a --onedir freeze (tan-cli#349). Every + # tag published before it -- v0.4.1, which is what `latest` resolves to + # today, and the v0.5.0-rc4 pre-release -- publishes a raw tan.exe under the + # same triple. Requesting the .zip unconditionally 404s on every tag that + # exists right now, which is what #356 reported. + # + # Decided by asking the release ITSELF which name it carries, through the + # checksums.txt just fetched: that file lists every asset in the release, it + # comes from the tag already pinned above, and it is fetched unconditionally + # anyway because it is the integrity source. No extra request, and no second + # source of truth -- a release is the only authority on what it contains. + # + # Two alternatives, both rejected (install.sh rejects them for the same + # reasons; the two scripts must not disagree about which asset a tag has): + # + # * Comparing $Version against v0.5.0. That is exactly the "second source + # of truth that drifts" #349 rejected on the extension side, and it also + # needs SemVer pre-release ordering -- v0.5.0-rc4 sorts BELOW v0.5.0, + # which [version] does not model at all ([version]"0.5.0-rc4" does not + # even parse). A bug there picks the wrong shape silently. + # * Sniffing the downloaded bytes' magic number (PK.. vs MZ), which IS + # #349's own rule for the extension. It does not transfer here: the + # extension holds a file at a path it has already fetched, so its bytes + # are in hand before the question is asked. These two shapes have + # different NAMES, so a name must be chosen before there are any bytes. + # + # None of this weakens the integrity check: the digest still comes from this + # same file and is still compared BEFORE anything is expanded or written to + # $Dir. + # ----------------------------------------------------------------------- + $sumsLines = Get-Content -LiteralPath $sumsTmp + function Get-DigestFor([string]$name) { + foreach ($line in $sumsLines) { + $parts = $line -split '\s+', 2 + # Exact field match, never a substring: `tan-x86_64-pc-windows-msvc.exe` + # is a SUFFIX of nothing here, but `tan-x86_64-pc-windows-msvc` is a + # prefix of both names this script asks about. + if ($parts.Count -eq 2 -and $parts[1].Trim() -eq $name) { return $parts[0].Trim() } + } + return $null } + + # Archive first, so a release carrying both is installed in the current + # shape rather than the legacy one. + $asset = $archiveAsset + $layout = "archive" + $want = Get-DigestFor $asset if (-not $want) { - # Outcome 2: fetched fine, but this asset is not in it. A release that - # shipped the binary and omitted it from checksums.txt is a release bug, - # and installing anyway is how it would stay one. - Write-Error "install.ps1: $asset is not listed in $Version's checksums.txt`nRefusing to install -- the digest file exists but does not cover this asset, so it cannot be verified. Report this against $repo; the release is incomplete." + $asset = $rawAsset + $layout = "raw" + $want = Get-DigestFor $asset + } + if (-not $want) { + # Outcome 2, widened by #356: this release lists no asset for this + # platform under EITHER name. It used to mean only "the release forgot + # to checksum an asset it shipped"; it now also covers "this platform + # has no asset here at all", so it must name both rather than assert the + # rarer one. + Write-Host "install.ps1: $Version lists no asset for $archPart-pc-windows-msvc in its checksums.txt -- neither $archiveAsset nor $rawAsset." -ForegroundColor Red + if ($archPart -eq "aarch64") { + Write-Error "install.ps1: there is no prebuilt Windows arm64 asset from v0.5.0 onward. The binary is a frozen build that must be produced on the architecture it runs on, and the release builds no Windows arm64 leg. Install from a checkout instead: git clone https://github.com/$repo && pip install ./tan-cli/python" + } else { + Write-Error "install.ps1: refusing to install. Check what $Version publishes: https://github.com/$repo/releases -- if an asset for this platform IS listed there, the release is incomplete (shipped but left out of checksums.txt) and should be reported against $repo; either way there is nothing here to verify against." + } + exit 1 + } + + $tmp = if ($layout -eq "archive") { "$tmpBase.zip" } else { "$tmpBase.exe" } + $url = "$baseUrl/$Version/$asset" + Write-Host "install.ps1: downloading $asset ($Version)..." + try { + Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing + } catch { + # Unlike before #356 this is no longer where a "never published" 404 + # surfaces -- the selection above already proved the release lists this + # asset -- so a failure here is a transport problem, or a release whose + # checksums.txt and uploaded assets disagree. + Write-Host "install.ps1: download failed: $url" -ForegroundColor Red + Write-Error "install.ps1: refusing to install. $Version's checksums.txt lists $asset, so the file is expected to exist -- this is most likely a network/proxy failure. If it is a 404, that release is inconsistent: https://github.com/$repo/releases" exit 1 } @@ -168,31 +262,107 @@ try { } Write-Host "install.ps1: sha256 OK ($got)" - Move-Item -LiteralPath $tmp -Destination $dest -Force + # ------------------------------------------------------------------------- + # Put it in place. Two layouts (tan-cli#356), and the two names SHADOW each + # other on PATH, so whichever one is not being installed must be removed -- + # not merely left alone. cmd.exe/PowerShell resolve a bare `tan` by walking + # PATHEXT in order (.COM;.EXE;.BAT;.CMD by default), so a leftover tan.exe + # always beats a tan.cmd sitting beside it. A stale tan.exe next to a fresh + # launcher would silently keep running the previous release forever; a stale + # tan.cmd next to a fresh tan.exe is the harmless direction on PATH but + # still points at a tan-cli-lib\ that is about to be deleted, so it goes too. + # + # archive (v0.5.0+, tan-cli#349): $tmp is a verified .zip of a --onedir + # freeze, not an executable -- expand it into a private staging dir first + # (no admin needed for that either), THEN move the unpacked tree into place + # and write the launcher last, mirroring install.sh's shape (staging dir, + # unwrap, move into place, launcher last) rather than inventing a second + # approach. The archive's one top-level entry is `tan\`, matching + # build_binary.sh's `shutil.make_archive(..., base_dir="tan")`, containing + # `tan.exe` (the real executable) plus `_internal\` (its runtime). + # + # raw (every tag before v0.5.0): $tmp already IS tan.exe and becomes + # $Dir\tan.exe directly -- no launcher, no tan-cli-lib\. + # ------------------------------------------------------------------------- + $destCmd = Join-Path $Dir "tan.cmd" + $destExe = Join-Path $Dir "tan.exe" + $dest = if ($layout -eq "archive") { $destCmd } else { $destExe } + foreach ($stale in @($destCmd, $destExe)) { + if ($stale -ne $dest -and (Test-Path -LiteralPath $stale)) { + Write-Host "install.ps1: removing $stale left by a previous install (it would otherwise shadow $dest on PATH)." + Remove-Item -LiteralPath $stale -Force -ErrorAction SilentlyContinue + } + } + # Unconditional: on the raw path there is no $LibDir to create, and leaving + # a previous install's ~14 MB runtime behind orphans it. + if (Test-Path -LiteralPath $LibDir) { Remove-Item -LiteralPath $LibDir -Recurse -Force } + + if ($layout -eq "archive") { + Expand-Archive -LiteralPath $tmp -DestinationPath $stage -Force + $stagedExe = Join-Path $stage "tan\tan.exe" + if (-not (Test-Path -LiteralPath $stagedExe)) { + Write-Error "install.ps1: $asset did not contain tan\tan.exe after extraction -- archive layout changed?" + exit 1 + } + Move-Item -LiteralPath (Join-Path $stage "tan") -Destination $LibDir -Force + + # A thin launcher, not a symlink (symlinks need elevation/Developer Mode + # on Windows by default and would not survive `-System` cleanly either): + # a .cmd, because PATHEXT resolves `tan` to it the same way it would an + # .exe, and it gives a future reader somewhere obvious to add a wrapper + # concern without editing the generated tree in place. `%~dp0` (the + # launcher's own directory) rather than a baked-in absolute path, so the + # launcher keeps working if $Dir is ever relocated as a unit. + $launcherContent = @' +@echo off +rem Generated by tan install.ps1 (tan-cli#349) -- do not edit by hand. +rem Re-run install.ps1 to update both this launcher and %~dp0tan-cli-lib. +"%~dp0tan-cli-lib\tan.exe" %* +exit /b %ERRORLEVEL% +'@ + # ASCII, no BOM: a BOM ahead of `@echo off` corrupts cmd.exe's parse of + # the first line on some Windows builds. + Set-Content -LiteralPath $dest -Value $launcherContent -Encoding ascii -NoNewline + } else { + Move-Item -LiteralPath $tmp -Destination $dest -Force + } } finally { - Remove-Item -LiteralPath $tmp, $sumsTmp -Force -ErrorAction SilentlyContinue + # One wildcard over the shared GUID stem: $tmp/$sumsTmp/$stage all hang off + # $tmpBase, and $tmp may not even be assigned yet if the selection above + # refused (Set-StrictMode makes naming an unassigned variable a throw, which + # inside `finally` would mask the real error). + Remove-Item -Path "$tmpBase*" -Recurse -Force -ErrorAction SilentlyContinue } # Add $Dir to the chosen PATH scope if absent. Machine scope requires admin; # SetEnvironmentVariable throws a clear permission error if not elevated. $curPath = [Environment]::GetEnvironmentVariable("Path", $scope) if (-not ($curPath -split ';' | Where-Object { $_ -eq $Dir })) { - $newPath = if ([string]::IsNullOrEmpty($curPath)) { $Dir } else { "$curPath;$Dir" } - [Environment]::SetEnvironmentVariable("Path", $newPath, $scope) - Write-Host "install.ps1: added $Dir to the $scope Path -- restart the terminal for it to take effect." + if ($NoModifyPath) { + Write-Host "install.ps1: $Dir is not on the $scope Path -- add it yourself, or re-run without -NoModifyPath." + } else { + $newPath = if ([string]::IsNullOrEmpty($curPath)) { $Dir } else { "$curPath;$Dir" } + [Environment]::SetEnvironmentVariable("Path", $newPath, $scope) + Write-Host "install.ps1: added $Dir to the $scope Path -- restart the terminal for it to take effect." + } } -Write-Host "install.ps1: installed tan -> $dest" +if ($layout -eq "archive") { + Write-Host "install.ps1: installed tan -> $dest (runtime: $LibDir)" +} else { + Write-Host "install.ps1: installed tan -> $dest" +} # The sha256 check above proves the BYTES are the ones the release published; # it says nothing about whether THIS host can execute them. `& $dest --version` # with its exit code unchecked does not fail the script even when the binary # cannot run (e.g. a missing runtime dependency) -- PowerShell does not turn a # non-zero native exit code into a terminating error on its own, $ErrorAction- # Preference or not, so this would report success regardless. Capture the -# output and check $LASTEXITCODE instead. A verified-but-unrunnable binary is -# removed rather than left at $dest and on the $scope Path: it is the correct -# bytes for a host this is NOT, and leaving it in place turns every later -# `tan` invocation into this same opaque failure instead of a clear "not found". +# output and check $LASTEXITCODE instead. A verified-but-unrunnable install is +# removed rather than left at $dest/$LibDir and on the $scope Path: it is the +# correct bytes for a host this is NOT, and leaving it in place turns every +# later `tan` invocation into this same opaque failure instead of a clear +# "not found". try { $verifyOut = (& $dest --version 2>&1 | Out-String).Trim() $verifyExit = $LASTEXITCODE @@ -205,6 +375,11 @@ if ($verifyExit -eq 0) { } else { Write-Host "install.ps1: installed binary failed to run: $verifyOut" -ForegroundColor Red Remove-Item -LiteralPath $dest -Force -ErrorAction SilentlyContinue - Write-Error "install.ps1: removed $dest -- install failed. This host may be missing a runtime dependency the binary needs, or security software may have altered it. Install from a checkout instead: git clone https://github.com/$repo && pip install ./tan-cli/python" + Remove-Item -LiteralPath $LibDir -Recurse -Force -ErrorAction SilentlyContinue + # Names only what actually existed: on the raw layout there is no $LibDir, + # and telling a user a path was removed that was never there sends them + # looking for it. + $removed = if ($layout -eq "archive") { "$dest and $LibDir" } else { $dest } + Write-Error "install.ps1: removed $removed -- install failed. This host may be missing a runtime dependency the binary needs, or security software may have altered it. Install from a checkout instead: git clone https://github.com/$repo && pip install ./tan-cli/python" exit 1 } diff --git a/install.sh b/install.sh index b1d7557d..cafa9b2a 100755 --- a/install.sh +++ b/install.sh @@ -1,16 +1,36 @@ #!/usr/bin/env sh # SPDX-License-Identifier: Apache-2.0 # -# tan installer for Linux + macOS. Downloads the prebuilt `tan` binary for this -# platform from GitHub Releases and installs it. By DEFAULT it installs to a -# user-local dir (~/.local/bin) so NO sudo/admin is needed. Pass --system to -# install to /usr/local/bin (that path needs elevated permission -> uses sudo). +# tan installer for Linux + macOS. Downloads the prebuilt `tan` release +# archive for this platform from GitHub Releases, unpacks it, and installs a +# launcher. By DEFAULT it installs to a user-local dir (~/.local/bin) so NO +# sudo/admin is needed. Pass --system to install to /usr/local/bin (that path +# needs elevated permission -> uses sudo). +# +# The asset's SHAPE depends on which release you install, and this script reads +# that off the release rather than assuming it (tan-cli#356): +# +# * From v0.5.0 (tan-cli#349) the asset is a PyInstaller --onedir freeze +# archived as a .tar.gz, not a raw executable: --onefile re-extracted its +# whole runtime into a fresh temp dir on EVERY invocation, which measured +# 13-19 s on macOS (unsigned re-extracted .dylibs get re-verified by the OS +# on every load). $INSTALL_DIR/tan is then a thin launcher script, not the +# binary itself -- the unpacked freeze lives in $INSTALL_DIR/tan-cli-lib/. +# * Every tag published BEFORE v0.5.0 -- including v0.4.1, which is what +# `latest` resolves to today, and the v0.5.0-rc4 pre-release -- publishes a +# RAW executable under the same triple with no extension. That one is +# installed as-is, no launcher and no tan-cli-lib/. # # curl -fsSL https://raw.githubusercontent.com/alplabai/tan-cli/main/install.sh | sh # ./install.sh [--version vX.Y.Z] [--dir ] [--system] set -eu REPO="alplabai/tan-cli" +# Where release assets are fetched from. Overridable for an internal mirror +# that carries the same / layout (and it is how this installer's own +# tests serve a fixture release offline). `latest` is still resolved against +# github.com -- a mirror hosts bytes, it does not decide which tag is current. +BASE_URL="${TAN_INSTALL_BASE_URL:-https://github.com/${REPO}/releases/download}" VERSION="latest" INSTALL_DIR="${TAN_INSTALL_DIR:-$HOME/.local/bin}" MODIFY_PATH=1 @@ -74,7 +94,22 @@ if [ "$os_part" = "unknown-linux-gnu" ]; then is_musl=1 fi if [ "$is_musl" = "1" ]; then - echo "install.sh: this host's libc is musl (e.g. Alpine) -- no Linux asset is published for it. From v0.5.0 the binary is a PyInstaller freeze, which cannot produce the static musl artefact older Rust releases did; the only Linux asset now is -unknown-linux-gnu, and it cannot exec on a musl host." >&2 + # Fires before the tag is even resolved -- deliberately, even though + # that makes this refusal a HOST property, not a per-tag capability, + # while the raw/archive selection below IS genuinely per-tag + # (tan-cli#356 adversarial review, item 4): the retired Rust tags + # (v0.4.1 and older) really do publish a `-unknown-linux-musl` asset + # this script could fetch and run, but this installer does not + # resurrect that path for ANY --version, past or future. A musl + # consumer piecing together "works for old tags, refuses for new + # ones, and the boundary is which --version you happened to type" is + # a worse promise than one clear, tag-independent refusal -- and see + # the `Linux)` case above for why v0.5.0+ cannot ship musl again even + # if a future tag wanted to (a PyInstaller freeze dynamic-links the + # loader it was built against). Documented the same way in + # docs/release-contract.md's musl section, so this decision is not + # something only this comment knows about. + echo "install.sh: this host's libc is musl (e.g. Alpine) -- refusing before even resolving --version. This installer does not support musl for ANY tag, including older ones (v0.4.1 and earlier) that genuinely still publish a -unknown-linux-musl asset: from v0.5.0 the binary is a PyInstaller freeze, which cannot produce the static musl artefact those older Rust releases did, and the only Linux asset going forward is -unknown-linux-gnu, which cannot exec on a musl host." >&2 echo "install.sh: refusing to install. Install from a checkout instead: git clone https://github.com/${REPO} && pip install ./tan-cli/python" >&2 exit 1 fi @@ -88,15 +123,23 @@ x86_64 | amd64) arch_part="x86_64" ;; *) echo "install.sh: unsupported architecture '$arch'" >&2; exit 1 ;; esac -asset="tan-${arch_part}-${os_part}" - # One HTTP download, curl or wget, quiet about nothing. Both branches keep the # flags they had inline before (`--proto '=https' --tlsv1.2`, and no -q on # wget) -- the transport's own error IS the primary diagnostic, so it is never # swallowed in favour of a guess. +# +# `--proto` pins the transport to the scheme the URL already names, so a +# redirect cannot silently downgrade it. Derived from $BASE_URL rather than +# spelled '=https' inline only so an overridden TAN_INSTALL_BASE_URL (a mirror, +# or this installer's own tests) may be plain http; the default base IS https, +# so the default pin is exactly the '=https' it has always been. +case "$BASE_URL" in +https://*) proto="=https" ;; +*) proto="=http,https" ;; +esac download() { # $1 = url, $2 = output path; returns non-zero on failure if command -v curl >/dev/null 2>&1; then - curl -fSL --proto '=https' --tlsv1.2 -o "$2" "$1" + curl -fSL --proto "$proto" --tlsv1.2 -o "$2" "$1" elif command -v wget >/dev/null 2>&1; then wget -O "$2" "$1" else @@ -134,31 +177,15 @@ if [ "$VERSION" = "latest" ]; then VERSION="$resolved" echo "install.sh: latest is ${VERSION}." fi -url="https://github.com/${REPO}/releases/download/${VERSION}/${asset}" -sums_url="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt" +sums_url="${BASE_URL}/${VERSION}/checksums.txt" tmp="$(mktemp)" sums="$(mktemp)" -trap 'rm -f "$tmp" "$sums"' EXIT -echo "install.sh: downloading tan (${arch_part}-${os_part}, ${VERSION})..." -dl_ok=1 -download "$url" "$tmp" || dl_ok=0 -if [ "$dl_ok" = "0" ]; then - echo "install.sh: download failed: ${url}" >&2 - # The transport error above says THAT it failed, never why, and a 404 for - # an asset that was never published looks identical to a proxy outage. Name - # the causes this script can actually know; guess at nothing else. - case "${arch_part}-${os_part}" in - aarch64-unknown-linux-gnu) - echo "install.sh: note -- there is no prebuilt Linux arm64 asset from v0.5.0 onward. The binary is a frozen build that must be produced on the architecture it runs on, and the release builds no arm64 Linux. Install from a checkout instead: git clone https://github.com/${REPO} && pip install ./tan-cli/python" >&2 - ;; - esac - echo "install.sh: if this is a 404 rather than a network failure, check which assets ${VERSION} actually publishes: https://github.com/${REPO}/releases" >&2 - exit 1 -fi +stage="$(mktemp -d)" +trap 'rm -f "$tmp" "$sums"; rm -rf "$stage"' EXIT # --------------------------------------------------------------------------- -# Verify what landed against the checksums.txt published in the SAME release. +# Verify what lands against the checksums.txt published in the SAME release. # # TLS says we talked to github.com. It does not say github.com handed us the # bytes we published, and it says nothing at all about a proxy, a cache, or a @@ -175,12 +202,18 @@ fi # offline behind a corporate proxy and being handed a tampered binary are not # the same situation and must not read the same. (#389 reached the same shape # from the other side.) Nothing is written to the install dir on any of them -- -# the binary is still in $tmp here and the trap removes it. +# the downloaded asset is still in $tmp here and the trap removes it. +# +# checksums.txt is fetched FIRST, before the asset, because it is now also the +# asset MANIFEST (see the selection block below). Checking for a sha256 tool +# first in turn means a host that has none refuses before spending a ~20 MB +# download, instead of after -- and a refusal after a long download reads like +# a download failure. # --------------------------------------------------------------------------- if command -v sha256sum >/dev/null 2>&1; then - got="$(sha256sum "$tmp" | cut -d' ' -f1)" + sha256_of() { sha256sum "$1" | cut -d' ' -f1; } elif command -v shasum >/dev/null 2>&1; then - got="$(shasum -a 256 "$tmp" | cut -d' ' -f1)" + sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; } else # Outcome 4. Deliberately NOT a warn-and-continue, and deliberately no # --no-verify escape hatch: a flag that turns the check off is the hole @@ -191,25 +224,96 @@ else exit 1 fi -echo "install.sh: verifying against ${VERSION} checksums.txt..." +echo "install.sh: fetching ${VERSION} checksums.txt..." if ! download "$sums_url" "$sums" 2>/dev/null; then # Outcome 1: the digests could not be fetched. Says nothing about the - # binary -- which is exactly why it must not be worded like a mismatch. + # release's contents -- which is exactly why it must not be worded like a + # mismatch. echo "install.sh: could not fetch ${sums_url}" >&2 - echo "install.sh: refusing to install -- the binary downloaded, but there is nothing to check it against. This is a fetch failure, NOT evidence the binary is bad. Retry, or check a proxy/firewall." >&2 + echo "install.sh: refusing to install -- that file is both the list of assets ${VERSION} publishes and the only thing to verify a download against, so without it there is nothing to fetch and nothing to check. This is a fetch failure, NOT evidence anything is wrong with the release. Retry, or check a proxy/firewall." >&2 exit 1 fi -want="$(awk -v a="$asset" '$2 == a { print $1 }' "$sums" | head -1)" +# --------------------------------------------------------------------------- +# WHICH SHAPE does this release publish? (tan-cli#356) +# +# From v0.5.0 an asset is a .tar.gz of a --onedir freeze (tan-cli#349). Every +# tag published before it -- v0.4.1, which is what `latest` resolves to today, +# and the v0.5.0-rc4 pre-release -- publishes a RAW executable under the same +# triple with no extension. Requesting the archive unconditionally 404s on +# every tag that exists right now, which is what #356 reported. +# +# Decided by asking the release ITSELF which name it carries, through the +# checksums.txt just fetched: that file lists every asset in the release, it +# comes from the tag already pinned above, and it is fetched unconditionally +# anyway because it is the integrity source. So this costs no extra request and +# introduces no second source of truth -- a release is the only authority on +# what that release contains, and a tag cut years from now needs no edit here. +# +# Two alternatives, both rejected: +# +# * Comparing the resolved version against v0.5.0. That is exactly the +# "second source of truth that drifts" #349 rejected on the extension side, +# and in POSIX sh it also means hand-rolling SemVer pre-release ordering -- +# v0.5.0-rc4 must sort BELOW v0.5.0, which a naive string or field compare +# gets backwards. A bug in that comparison picks the wrong shape silently. +# * Sniffing the downloaded bytes' magic number (gzip 1f 8b vs an ELF/Mach-O +# header), which IS #349's own rule for the extension. It does not transfer +# here: the extension holds a file at a path it has already fetched, so its +# bytes are in hand before the question is asked. These two shapes have +# different NAMES, so a name must be chosen before there are any bytes to +# sniff at all. +# +# None of this weakens the integrity check. The digest still comes from this +# same file, and it is still compared BEFORE the archive is unpacked or +# anything is written to $INSTALL_DIR. +# --------------------------------------------------------------------------- +digest_for() { awk -v a="$1" '$2 == a { print $1 }' "$sums" | head -1; } + +# .tar.gz, never .zip: install.sh only ever targets Linux/macOS (Windows uses +# install.ps1, whose asset is the .zip build_binary.sh produces for that OS). +archive_asset="tan-${arch_part}-${os_part}.tar.gz" +raw_asset="tan-${arch_part}-${os_part}" +# Archive first, so a release carrying both is installed in the current shape +# rather than the legacy one. +asset="$archive_asset" +layout="archive" +want="$(digest_for "$asset")" +if [ -z "${want:-}" ]; then + asset="$raw_asset" + layout="raw" + want="$(digest_for "$asset")" +fi if [ -z "${want:-}" ]; then - # Outcome 2: fetched fine, but this asset is not in it. A release that - # shipped the binary and omitted it from checksums.txt is a release bug, - # and silently installing anyway is how it would stay one. - echo "install.sh: ${asset} is not listed in ${VERSION}'s checksums.txt" >&2 - echo "install.sh: refusing to install -- the digest file exists but does not cover this asset, so it cannot be verified. Report this against ${REPO}; the release is incomplete." >&2 + # Outcome 2, widened by #356: this release lists no asset for this platform + # under EITHER name. Previously this could only be read as "the release + # forgot to checksum an asset it shipped"; now it also covers "this + # platform has no asset here at all", so it must name both possibilities + # rather than assert the rarer one. + echo "install.sh: ${VERSION} lists no asset for ${arch_part}-${os_part} in its checksums.txt -- neither ${archive_asset} nor ${raw_asset}." >&2 + case "${arch_part}-${os_part}" in + aarch64-unknown-linux-gnu) + echo "install.sh: note -- there is no prebuilt Linux arm64 asset from v0.5.0 onward. The binary is a frozen build that must be produced on the architecture it runs on, and the release builds no arm64 Linux. Install from a checkout instead: git clone https://github.com/${REPO} && pip install ./tan-cli/python" >&2 + ;; + esac + echo "install.sh: refusing to install. Check what ${VERSION} publishes: https://github.com/${REPO}/releases -- if an asset for this platform IS listed there, the release is incomplete (shipped but left out of checksums.txt) and should be reported against ${REPO}; either way there is nothing here to verify against." >&2 + exit 1 +fi + +url="${BASE_URL}/${VERSION}/${asset}" +echo "install.sh: downloading ${asset} (${VERSION})..." +if ! download "$url" "$tmp"; then + # The transport error above says THAT it failed, never why. Unlike before + # #356 this is no longer the place a "never published" 404 surfaces -- the + # selection above already proved the release lists this asset -- so a + # failure here is a transport problem or a release whose checksums.txt and + # uploaded assets disagree. + echo "install.sh: download failed: ${url}" >&2 + echo "install.sh: refusing to install. ${VERSION}'s checksums.txt lists ${asset}, so the file is expected to exist -- this is most likely a network/proxy failure. If it is a 404, that release is inconsistent: https://github.com/${REPO}/releases" >&2 exit 1 fi +got="$(sha256_of "$tmp")" if [ "$got" != "$want" ]; then # Outcome 3: the one that means something is actually wrong. echo "install.sh: SHA256 MISMATCH for ${asset} (${VERSION})" >&2 @@ -220,27 +324,94 @@ if [ "$got" != "$want" ]; then fi echo "install.sh: sha256 OK (${got})" -chmod +x "$tmp" - dest="${INSTALL_DIR}/tan" +LIB_DIR="${INSTALL_DIR}/tan-cli-lib" + +# --------------------------------------------------------------------------- +# Prepare $payload -- the one file that ends up at $dest -- per layout. Both +# branches finish unprivileged, in temp space: the placement block below is the +# only thing that ever touches $INSTALL_DIR, so it is the only thing that ever +# needs sudo. +# +# archive (v0.5.0+, tan-cli#349): $tmp is a verified .tar.gz of a --onedir +# freeze, not an executable. The archive's one top-level entry is `tan/` +# (matching build_binary.sh's `shutil.make_archive(..., base_dir="tan")`), +# containing `tan` (the real executable) plus `_internal/` (its runtime). `mv` +# RENAMES that folder onto $LIB_DIR below rather than nesting it inside -- +# POSIX `mv src dst` makes `dst` BE `src` when `dst` does not already exist, it +# does not create `dst/src` -- so once moved the executable is at +# `$LIB_DIR/tan`, not `$LIB_DIR/tan/tan`. (Checked directly against a real +# archive while writing this: the nested path was the first thing tried, and it +# is wrong.) +# +# raw (every tag before v0.5.0, tan-cli#356): $tmp already IS the executable. +# It becomes $dest directly and there is no $LIB_DIR at all. +# --------------------------------------------------------------------------- +if [ "$layout" = "archive" ]; then + tar -xzf "$tmp" -C "$stage" + if [ ! -f "$stage/tan/tan" ]; then + echo "install.sh: ${asset} did not contain tan/tan after extraction -- archive layout changed?" >&2 + exit 1 + fi + chmod +x "$stage/tan/tan" + # a+rX over the whole tree, not just +x on the executable: `mktemp -d` makes + # $stage 0700, so a --system install would otherwise leave a root-owned 0700 + # runtime under /usr/local/bin that the launcher on PATH cannot read for any + # other user -- an install that only works for the account that ran sudo. + chmod -R a+rX "$stage/tan" + + # A thin POSIX launcher, not a symlink: a symlink straight to $LIB_DIR/tan + # would still put a plain, unshimmed binary on PATH, which is fine for `tan` + # itself but gives a future reader nowhere obvious to add a wrapper concern + # (e.g. an env var) without editing the generated tree in place. + payload="$(mktemp)" + cat >"$payload" < use sudo explicitly so -# the admin step is visible, never silent. +# the admin step is visible, never silent. A function rather than a `$sudo` +# variable so every expansion below stays quoted (getting-started.yml runs +# `shellcheck --shell=sh` over this file). if mkdir -p "$INSTALL_DIR" 2>/dev/null && [ -w "$INSTALL_DIR" ]; then - mv "$tmp" "$dest" + as_root() { "$@"; } else echo "install.sh: ${INSTALL_DIR} needs elevated permission -- running sudo (admin)." - sudo mkdir -p "$INSTALL_DIR" - sudo mv "$tmp" "$dest" - sudo chmod +x "$dest" + as_root() { sudo "$@"; } + as_root mkdir -p "$INSTALL_DIR" fi -# $tmp has been moved to $dest; $sums has not, so clear the trap only after -# removing it by hand -- otherwise a successful install is the one path that -# leaves a temp file behind. -rm -f "$sums" +# Wholesale, not a merge, so a re-install replaces the old freeze rather than +# mixing two releases' files. Unconditional: a raw install over a previous +# archive install must not leave ~14 MB of orphaned runtime behind either. +as_root rm -rf "$LIB_DIR" +if [ "$layout" = "archive" ]; then + as_root mv "$stage/tan" "$LIB_DIR" +fi +as_root mv "$payload" "$dest" +# 755, not `chmod +x`: both $payload sources are `mktemp` files, i.e. 0600, and +# +x on 0600 is 0700 -- an owner-only binary, which is precisely what --system +# into /usr/local/bin must not produce. +as_root chmod 755 "$dest" +# $payload and (on the archive path) $stage have been consumed by the moves +# above; $sums and, on the archive path, $tmp have not, so clear the trap only +# after removing them by hand -- otherwise a successful install is the one path +# that leaves a temp file behind. +rm -f "$sums" "$tmp" +rm -rf "$stage" trap - EXIT -echo "install.sh: installed tan -> ${dest}" +if [ "$layout" = "archive" ]; then + echo "install.sh: installed tan -> ${dest} (runtime: ${LIB_DIR})" +else + echo "install.sh: installed tan -> ${dest}" +fi case ":${PATH}:" in *":${INSTALL_DIR}:"*) : # already on PATH -- 'tan' works from any shell @@ -284,7 +455,11 @@ if verify_out="$("$dest" --version 2>&1)"; then echo "install.sh: verified: ${verify_out}" else echo "install.sh: installed binary failed to run: ${verify_out}" >&2 - rm -f "$dest" - echo "install.sh: removed ${dest} -- install failed. If the message above names a GLIBC symbol, this host's glibc is older than the release floor; install from a checkout instead: git clone https://github.com/${REPO} && pip install ./tan-cli/python" >&2 + # as_root, not a bare rm: a --system install put these there with sudo, and + # a bare rm would fail silently, leaving the broken install on PATH under a + # message claiming it was removed. + as_root rm -f "$dest" + as_root rm -rf "$LIB_DIR" + echo "install.sh: removed ${dest} and ${LIB_DIR} -- install failed. If the message above names a GLIBC symbol, this host's glibc is older than the release floor; install from a checkout instead: git clone https://github.com/${REPO} && pip install ./tan-cli/python" >&2 exit 1 fi diff --git a/npm-shim/.gitignore b/npm-shim/.gitignore new file mode 100644 index 00000000..484b9667 --- /dev/null +++ b/npm-shim/.gitignore @@ -0,0 +1,9 @@ +# postinstall.js output, not source: the unpacked PyInstaller freeze +# (`tan-cli-lib/{tan[.exe], _internal/}`, ~40 MB) and the staging directory it +# is assembled in. Anyone who runs `npm install` in this directory materialises +# both, and this repo has already committed a build tree by accident once +# (python/.venv-e2e, 3327 files, public history). A package-local file rather +# than a root rule so it travels with the thing it describes; npm packs from +# package.json's `files` allowlist, so neither is ever published either. +/tan-cli-lib/ +/.tan-install-*/ diff --git a/npm-shim/README.md b/npm-shim/README.md index c71cd373..271a8bb2 100644 --- a/npm-shim/README.md +++ b/npm-shim/README.md @@ -3,9 +3,11 @@ # @alplabai/tan > **This package is not published.** `npm view @alplabai/tan` answers -> `E404 Not Found` at every version, and `release.yml`'s `publish_npm` job is -> switched off (it names the two reasons). Nothing below works until it is -> turned back on — use the install scripts or a release asset instead. +> `E404 Not Found` at every version. `release.yml`'s `publish_npm` job only +> runs on a final (non-pre-release) tag, and even then is gated OFF by +> default behind the `TAN_NPM_PUBLISH` repository variable — see +> [Releasing](#releasing) for why. Nothing below works until it is armed — +> use the install scripts or a release asset instead. npm distribution shim for the **`tan`** CLI (Alp Lab's standalone build CLI). Installing this package downloads the platform-specific binary from the @@ -30,12 +32,38 @@ the [repo README](../README.md). ## How it works - `postinstall.js` maps the host platform/arch to a release target triple, - downloads the RAW `tan-[.exe]` binary from - `https://github.com/alplabai/tan-cli/releases/download/v/`, verifies - its SHA-256 against the release's `checksums.txt`, then writes it into - `binary/` and `chmod +x`s it. tan's release ships one uncompressed binary per - triple (not a `.tar.gz`), so there is no archive to extract. -- `bin/tan.js` forwards `tan …` invocations to that native binary. + fetches the release's `checksums.txt` from + `https://github.com/alplabai/tan-cli/releases/download/v/` and asks + it which asset this tag actually published for that triple — the archive + name (`tan-.zip` / `.tar.gz`) first, the raw name + (`tan-[.exe]`) as fallback — downloads whichever one it finds, + verifies its SHA-256 against the pinned digest, and only then installs it. +- `bin/tan.js` forwards `tan …` invocations to `tan-cli-lib/tan[.exe]`. + +**From v0.5.0 — the transition tag, not cut yet — the asset becomes an +archive of a PyInstaller `--onedir` freeze**, not a raw binary +([#349](https://github.com/alplabai/tan-cli/issues/349)). Every tag published +so far, including `v0.5.0-rc4`, still ships the raw binary this shim asks for +as a fallback; asking for the archive name unconditionally was +[#362](https://github.com/alplabai/tan-cli/issues/362) — a name no published +tag carries yet, so it 404'd, including at this shim's own pinned version. +`postinstall.js`'s `selectRelease` decides which shape a given tag actually +published from its `checksums.txt` (the same rule `install.sh` / +`install.ps1` follow, [#356](https://github.com/alplabai/tan-cli/issues/356)), +never from the version number, so both shapes install correctly for as long as +raw tags remain installable. + +The archive's one top-level entry is `tan/`, holding `tan` (`tan.exe` on +Windows) plus `_internal/`, the runtime — **the executable does not run +without that sibling**, which is why an archive installs as a directory and +`tan` on `PATH` is a launcher, exactly as `install.sh` / `install.ps1` do it. A +raw binary installs the same way — as a one-file `tan-cli-lib/` directory — so +`bin/tan.js` never needs to know which shape this tag shipped. Unpacking an +archive shells out to the system `tar` (bsdtar on Windows and macOS, GNU tar on +Linux): node's stdlib reads neither `tar` nor `zip`, and a `postinstall` +script is the last place to want a dependency. Entries are checked before +extraction — an absolute path, a `..` component, or anything outside `tan/` +aborts the install rather than being written. Prebuilt targets from v0.5.0: **Linux x64** (`-gnu`), **macOS x64/arm64** (Intel + Apple Silicon), **Windows x64** — four assets, not six. @@ -49,12 +77,19 @@ without a prebuilt binary can install from a checkout instead: ## Checksum verification The `release` workflow publishes a `checksums.txt` (GNU `sha256sum` output) -alongside the binaries. `postinstall.js` fetches it from the same release -and verifies the downloaded binary's SHA-256 against the pinned digest -**before** writing it to disk and `chmod +x`ing it. It **fails closed** — a -missing `checksums.txt`, a missing entry for the target asset, or a digest -mismatch aborts the install rather than running an unverified binary. -(Resolves [alplabai/tan-cli#11](https://github.com/alplabai/tan-cli/issues/11).) +alongside the assets. `postinstall.js` fetches it FIRST — before choosing an +asset name at all, since `checksums.txt` also doubles as the manifest of which +shape this tag published (see above) — and verifies the downloaded bytes' +SHA-256 against the pinned digest **before installing anything** — extraction +writes attacker-named paths to disk, so it belongs after the digest check, not +before it. It **fails closed**: a missing `checksums.txt`, a missing entry for +either candidate asset name, or a digest mismatch aborts the install rather +than extracting, `chmod +x`ing or running an unverified binary. The verified +bytes are then moved into place with renames, so `tan-cli-lib/` is either the +previous install or the new one, never a half-written freeze — and a failed +swap restores the previous install rather than losing it. +(Resolves [alplabai/tan-cli#11](https://github.com/alplabai/tan-cli/issues/11), +[#362](https://github.com/alplabai/tan-cli/issues/362).) ## Releasing @@ -63,20 +98,39 @@ mismatch aborts the install rather than running an unverified binary. `version` to match it exactly. This is enforced, not just documented: `python/scripts/version_check.py --selftest --tag` (run by `release.yml`'s `verify-version` job) fails the tag if they disagree — `postinstall.js` - resolves its download tag from `package.json`'s version alone - (`npm-shim/postinstall.js:25`), so a stale shim version silently fetches the - wrong release's binaries. `Cargo.toml` is deliberately **not** part of this - check any more: it versions the retired Rust crates, not the release - assets. + resolves its download TAG from `package.json`'s version alone + (`npm-shim/postinstall.js:49`), so a stale shim version silently fetches + from the wrong release. Which ASSET SHAPE it asks for at that tag is not + version-derived, though — `selectRelease` decides that from the tag's own + `checksums.txt` (see [How it works](#how-it-works)), which is what lets this + shim install correctly at both a raw-asset tag and an archive tag without + caring which one `package.json`'s version happens to be. `Cargo.toml` is + deliberately **not** part of this check any more: it versions the retired + Rust crates, not the release assets. 2. Tag `v` and push. `release.yml`: - freezes the four target binaries and attaches them to the GitHub release (`build` + `release` jobs); - - does **not** publish this package: `publish_npm` is `if: ${{ false }}`. - The crates.io job is gone entirely — the assets are no longer built from - `crates/`, so publishing `alp-tan-cli` would ship a different program - under the same name. - - Re-enabling `publish_npm` needs both: `postinstall.js`'s target map narrowed - to what the release actually publishes, and a working `NPM_TOKEN` (the - current one is a classic token on a 2FA account, so `npm publish` answers - `EOTP` and waits for an OTP that no unattended job can supply). + - only even ATTEMPTS to publish this package on a final (non-pre-release) + tag: `publish_npm`'s job-level `if` is + `startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-')` + (`release.yml`), so a `-rc*`/`-preN` tag skips this job entirely, the same + way it skips `make_latest`. The crates.io job is gone entirely — the + assets are no longer built from `crates/`, so publishing `alp-tan-cli` + would ship a different program under the same name. + - even on a final tag, publishing is OPT-IN and OFF by default: the job + reads `NPM_PUBLISH_ENABLED: ${{ vars.TAN_NPM_PUBLISH == 'true' }}` + (`release.yml`) and, unless that repository *variable* is set to `true`, + records `published=false` and explains why in the run summary rather + than attempting a publish — a deliberately loud no-op, not a silent + skip. + + Arming it needs BOTH steps, not just one: set the repository variable + `TAN_NPM_PUBLISH` to `true`, **and** replace `NPM_TOKEN` — the current one + is a classic token on a 2FA account, so `npm publish` answers `EOTP` and + waits for an OTP that no unattended job can supply; an *automation* (or + granular) token is exempt. If `TAN_NPM_PUBLISH` is `true` but `NPM_TOKEN` + is still empty, the job fails loudly rather than reporting a publish that + did not happen. The other stated blocker is gone: `postinstall.js`'s + target map is narrowed to the four targets the release publishes, and from + [#362](https://github.com/alplabai/tan-cli/issues/362) it asks for the + shape (archive or, for a pre-v0.5.0 tag, raw) those targets actually ship. diff --git a/npm-shim/bin/tan.js b/npm-shim/bin/tan.js index 958dd7f7..8785bea0 100644 --- a/npm-shim/bin/tan.js +++ b/npm-shim/bin/tan.js @@ -1,14 +1,22 @@ #!/usr/bin/env node // SPDX-License-Identifier: Apache-2.0 // -// Thin launcher: forwards argv to the native `tan` binary placed by +// Thin launcher: forwards argv to the native `tan` executable unpacked by // postinstall.js, inheriting stdio and the exit code. +// +// From tan-cli#362 the install is a PyInstaller `--onedir` tree, not a lone +// binary: `tan-cli-lib/{tan[.exe], _internal/}`. LIB_DIR and the executable +// name are IMPORTED from postinstall.js (which does nothing on require — it is +// guarded by `require.main === module`) rather than re-derived here, because a +// launcher and an installer that each compute the path separately is how you +// get a successful install with a `tan` that cannot find its binary. const path = require("path"); const { spawnSync } = require("child_process"); -const binName = process.platform === "win32" ? "tan.exe" : "tan"; -const binPath = path.join(__dirname, "..", "binary", binName); +const { LIB_DIR, exeName } = require("../postinstall.js"); + +const binPath = path.join(LIB_DIR, exeName(process.platform)); const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" }); diff --git a/npm-shim/postinstall.js b/npm-shim/postinstall.js index 7dd3a643..8b329227 100644 --- a/npm-shim/postinstall.js +++ b/npm-shim/postinstall.js @@ -1,29 +1,64 @@ #!/usr/bin/env node // SPDX-License-Identifier: Apache-2.0 // -// Downloads the platform-specific `tan` binary from the GitHub release that -// matches this package's version (tag `v`). tan's release ships a -// RAW, uncompressed binary per target triple (no .tar.gz — see -// docs/release-contract.md in the main repo), so this writes the download -// straight to disk and chmods it: no archive to unpack. Runs as the +// Downloads the platform-specific `tan` release asset from the GitHub release +// that matches this package's version (tag `v`), verifies it, unpacks +// it, and leaves the launcher pointing at the unpacked tree. Runs as the // package's `postinstall` step. // +// From v0.5.0 — the transition tag, NOT CUT YET — the asset becomes an +// ARCHIVE of a PyInstaller `--onedir` freeze (`tan-.zip` on Windows, +// `tan-.tar.gz` elsewhere), not a raw binary. Every tag published so +// far, including v0.5.0-rc4, still ships the raw `tan-[.exe]` this +// shim asked for before tan-cli#349/#356; tan-cli#349 changed the RELEASE, +// install.sh and install.ps1 to also handle the future archive shape, and +// left this shim behind asking ONLY for the archive name (tan-cli#362) — a +// name no tag published today carries, so every install 404'd at the +// download, including at this shim's own pinned version. Like the two +// installers, this shim now supports BOTH shapes and asks the release itself +// (via checksums.txt) which one a given tag actually published, rather than +// assuming from the version — see `selectRelease` below. +// +// The archive's one top-level entry is `tan/`, holding `tan` (`tan.exe` on +// Windows) plus `_internal/`, its runtime. THE EXECUTABLE DOES NOT RUN WITHOUT +// THAT SIBLING, so the two are installed together, as one tree, at +// `tan-cli-lib/`; `tan` on PATH stays `bin/tan.js`, a launcher that execs into +// it. Same shape install.sh / install.ps1 use for their hosts — one launcher +// path, one private runtime directory beside it. +// // Integrity: the `release` workflow publishes a `checksums.txt` (GNU -// `sha256sum` output) alongside the binaries. This script fetches it from the -// same release, verifies the downloaded binary's SHA-256 against the pinned -// digest, and FAILS CLOSED — a missing checksums.txt, a missing entry, or a -// mismatch aborts the install — before the binary is ever written and -// chmod +x'd. It never runs an unverified binary. (Resolves alplabai/tan-cli#11.) +// `sha256sum` output) alongside the assets. This script fetches it FIRST — +// before choosing an asset name at all, not just before downloading one — and +// verifies the downloaded bytes' SHA-256 against the pinned digest, and FAILS +// CLOSED — a missing checksums.txt, a missing entry for either candidate +// asset name, or a digest mismatch aborts the install BEFORE anything is +// unpacked, chmod +x'd, or put on the launcher's path. It never runs an +// unverified binary, and it never EXTRACTS an unverified archive either: +// extraction writes attacker-named paths to disk, so it belongs after the +// digest check, not before it. +// (Resolves alplabai/tan-cli#11, alplabai/tan-cli#362.) const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); +const { spawnSync } = require("child_process"); const pkg = require("./package.json"); const REPO = "alplabai/tan-cli"; const TAG = `v${pkg.version}`; -const BINARY_DIR = path.join(__dirname, "binary"); + +// The unpacked freeze: `tan-cli-lib/{tan[.exe], _internal/}`. Named for what +// install.sh and install.ps1 already call it on their hosts, so one name +// describes the layout everywhere. `bin/tan.js` reads LIB_DIR/EXE_NAME from +// here rather than re-deriving the path — the launcher and the installer +// disagreeing about where the binary lives is exactly the drift that produces +// a working install with a broken `tan`. +const LIB_DIR = path.join(__dirname, "tan-cli-lib"); +// The archive's single top-level directory (`shutil.make_archive(..., +// base_dir="tan")` in python/scripts/build_binary.sh). Anything else in an +// archive is a layout change or an attack; `assertSafeEntries` rejects both. +const ARCHIVE_ROOT = "tan"; // The platform -> triple table, and it must equal the "Targets published" // table in docs/release-contract.md, which install.sh also follows. @@ -75,16 +110,277 @@ function resolveTarget() { return target; } +/** + * The ARCHIVE release asset for a triple (the shape from v0.5.0 on). `.zip` + * on Windows / `.tar.gz` elsewhere is the release's own split + * (python/scripts/build_binary.sh picks the format from `$OS`), not this + * shim's preference — take both from the contract doc's asset column, which + * the test pins against. + * + * Takes `platform` rather than reading `process.platform` so the test can + * check every published target from one host; the extension in the other repo + * has to make the same choice and gets it wrong from the same distance. + */ +function assetName(platform, triple) { + return `tan-${triple}${platform === "win32" ? ".zip" : ".tar.gz"}`; +} + +/** + * The RAW release asset for a triple — every tag published up to and + * including v0.5.0-rc4 (tan-cli#356), no extension on Unix, `.exe` on + * Windows. Mirrors install.sh's `raw_asset="tan-${arch_part}-${os_part}"` and + * install.ps1's `$rawAsset = "tan-$archPart-pc-windows-msvc.exe"` — the three + * consumers must compose this name identically, which is what + * test/libc-mapping.test.js checks. + */ +function rawAssetName(platform, triple) { + return `tan-${triple}${platform === "win32" ? ".exe" : ""}`; +} + +/** The executable inside the archive (and inside LIB_DIR after install). */ +function exeName(platform) { + return platform === "win32" ? "tan.exe" : "tan"; +} + +// Node's stdlib has no tar reader and no zip reader, and this package +// deliberately has no dependencies (it is what npm runs BEFORE the user's own +// install finishes — a dependency here is a supply-chain edge on every +// consumer). So extraction shells out to the system archiver, the same call +// install.sh makes. `tar` is present on every supported host: Windows ships +// bsdtar as System32\tar.exe from Win10 1803, macOS ships bsdtar, Linux ships +// GNU tar. +// +// On Windows the System32 path is resolved EXPLICITLY, never a bare `tar` off +// PATH. Under Git Bash / MSYS — a very normal place to run npm on Windows — +// PATH's first `tar` is GNU tar (1.34 measured on a dev box here), and GNU tar +// cannot read the `.zip` this platform downloads: it answers "This does not +// look like a tar archive" and the install dies after a successful, verified +// download. bsdtar reads `.zip` AND `.tar.gz`, which is why one code path +// serves both asset shapes instead of a second Expand-Archive branch. +function tarBin() { + if (process.platform !== "win32") return "tar"; + const sys32 = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe"); + return fs.existsSync(sys32) ? sys32 : "tar"; +} + +/** Run the system archiver, surfacing its own stderr — never a guess. */ +function runTar(args) { + const bin = tarBin(); + const result = spawnSync(bin, args, { encoding: "utf8" }); + if (result.error) { + throw new Error( + `@alplabai/tan: could not run ${bin} (needed to unpack the release archive): ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error( + `@alplabai/tan: ${bin} ${args.join(" ")} exited ${result.status}: ${(result.stderr || "").trim()}`, + ); + } + return result.stdout || ""; +} + +/** + * Reject a listing that would write outside the destination, or that is not + * the single `tan/` tree the contract promises. Throws; the caller aborts + * before extracting. + * + * Checked here rather than trusting the archiver: bsdtar and GNU tar both + * strip a leading `/` and skip `..` members by default, but that is a DEFAULT + * (`-P`/`--absolute-names` turns it off) and the two differ in what they do + * with the remainder — silently dropping a member on one host and writing it + * on another is the worst of the available behaviours. Ten lines here are + * host-independent and are the thing the test can drive directly. + * + * Absolute-path detection does not use `path.isAbsolute` alone: that answers + * per the HOST, and the archive comes from another one. A `C:\…` member must + * be rejected while running on Linux, and a `/etc/…` member while running on + * Windows. + * + * Ceiling: this sees NAMES, so it does not catch a symlink member whose target + * escapes (the `tan/link -> /etc` + `tan/link/x` trick). Writing that archive + * means controlling the release, and the SHA-256 check above is what stands in + * the way of that; both tars also refuse to follow a symlink they just + * extracted. Parse `-tvf` if that ever stops being true. + */ +function assertSafeEntries(entries) { + for (const entry of entries) { + // Some tars prefix every member with `./`; strip it before judging. + const name = entry.replace(/^\.[/\\]/, ""); + if (!name) continue; + if (name.startsWith("/") || name.startsWith("\\") || /^[A-Za-z]:/.test(name)) { + throw new Error( + `@alplabai/tan: refusing to extract absolute path ${entry} from the release archive.`, + ); + } + const segments = name.split(/[/\\]/).filter(Boolean); + if (segments.includes("..")) { + throw new Error( + `@alplabai/tan: refusing to extract ${entry} from the release archive — it escapes the destination directory.`, + ); + } + if (segments[0] !== ARCHIVE_ROOT) { + throw new Error( + `@alplabai/tan: unexpected entry ${entry} in the release archive — every entry must live under ${ARCHIVE_ROOT}/ (archive layout changed? see docs/release-contract.md).`, + ); + } + } +} + +/** + * Unpack a VERIFIED archive into `destDir` and return the path to the + * executable inside it (`destDir/tan/tan[.exe]`). Validates the listing first, + * so nothing is written until every member is known to stay inside `destDir`. + */ +function unpackArchive(archivePath, destDir) { + const listing = runTar(["-tf", archivePath]) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (listing.length === 0) { + throw new Error(`@alplabai/tan: ${path.basename(archivePath)} is empty — nothing to install.`); + } + assertSafeEntries(listing); + + fs.mkdirSync(destDir, { recursive: true }); + runTar(["-xf", archivePath, "-C", destDir]); + + const exe = path.join(destDir, ARCHIVE_ROOT, exeName(process.platform)); + if (!fs.existsSync(exe)) { + throw new Error( + `@alplabai/tan: ${path.basename(archivePath)} did not contain ${ARCHIVE_ROOT}/${exeName(process.platform)} after extraction — archive layout changed?`, + ); + } + if (!fs.existsSync(path.join(destDir, ARCHIVE_ROOT, "_internal"))) { + // A freeze without its `_internal/` runtime starts and dies with a + // PyInstaller loader error, which reads like a broken binary rather than a + // broken download. Fail here, where the cause is still visible. + throw new Error( + `@alplabai/tan: ${path.basename(archivePath)} contained no ${ARCHIVE_ROOT}/_internal/ — the executable cannot run without it.`, + ); + } + return exe; +} + +/** + * Atomically replace `libDir` with `newTreeDir` (a fully-populated directory + * already sitting inside `stage`). Moves whatever is currently at `libDir` + * aside first rather than deleting it, so a failed second rename (on Windows: + * EPERM/EBUSY, while a `tan` from this install is still running) restores it + * instead of losing it — shared by the archive and raw install paths below so + * they cannot drift on this invariant. + * + * INVARIANT, and the reason for `error.preserveStage`: if the rollback rename + * (`previous -> libDir`) ALSO fails, `previous` (a path under `stage`) is at + * that point the ONLY surviving copy of the user's pre-install `tan` — the + * whole reason this function moves the old tree aside instead of deleting it + * up front. The thrown error is marked `preserveStage: true` so the caller's + * cleanup (`fs.rmSync(stage, {recursive: true})`) skips deleting `stage` + * instead of erasing the exact install this dance exists to protect. + */ +function swapIntoPlace(stage, newTreeDir, libDir) { + let previous = null; + if (fs.existsSync(libDir)) { + previous = path.join(stage, "previous"); + fs.renameSync(libDir, previous); + } + try { + fs.renameSync(newTreeDir, libDir); + } catch (error) { + if (!previous) throw error; + try { + fs.renameSync(previous, libDir); + } catch (rollbackError) { + const combined = new Error( + `@alplabai/tan: install failed (${error.message}) AND could not restore the previous install ` + + `(${rollbackError.message}). The previous install survives at ${previous} — move it to ${libDir} by hand.`, + ); + combined.preserveStage = true; + throw combined; + } + throw error; + } +} + +/** + * Write, unpack and install the verified ARCHIVE bytes as `libDir` (`LIB_DIR`, + * i.e. the launcher's target, unless a test says otherwise). + * + * The whole tree is assembled in a staging directory and moved into place with + * renames, so `libDir` is never a half-extracted freeze: it is the previous + * install, or the new one. Staging is created BESIDE `libDir`, not in + * `os.tmpdir()`, because `fs.renameSync` is atomic only within one filesystem + * and answers EXDEV across two — on Windows the temp dir is routinely on + * another volume. + */ +function installArchive(bytes, asset, libDir = LIB_DIR) { + fs.mkdirSync(path.dirname(libDir), { recursive: true }); + const stage = fs.mkdtempSync(path.join(path.dirname(libDir), ".tan-install-")); + let preserveStage = false; + try { + const archivePath = path.join(stage, asset); + fs.writeFileSync(archivePath, bytes); + const exe = unpackArchive(archivePath, stage); + if (process.platform !== "win32") { + // tar preserves the mode build_binary.sh set, but an archive repacked by + // hand may not; chmod unconditionally, as both installers do. + fs.chmodSync(exe, 0o755); + } + swapIntoPlace(stage, path.join(stage, ARCHIVE_ROOT), libDir); + } catch (error) { + preserveStage = Boolean(error && error.preserveStage); + throw error; + } finally { + // See swapIntoPlace's doc comment: `preserveStage` is set only when the + // rollback itself failed, i.e. `stage/previous` is the sole surviving + // copy of the pre-install `tan`. Deleting `stage` unconditionally here is + // exactly the bug that would destroy it on the one path it must survive. + if (!preserveStage) fs.rmSync(stage, { recursive: true, force: true }); + } +} + +/** + * Write and install verified RAW executable bytes as `libDir` — the shape + * every tag up to and including v0.5.0-rc4 publishes (tan-cli#356). Staged as + * a one-file directory (`stage/new/tan[.exe]`) rather than writing straight + * to `libDir`, so it lands through the exact same `swapIntoPlace` atomic swap + * `installArchive` uses and gets the same crash-safety and rollback + * invariant, and so `libDir` ends up shaped identically either way — a + * directory holding the executable — which is what lets `bin/tan.js` exec + * `LIB_DIR/exeName(...)` unconditionally, with no "was this tag raw or + * archived" branch of its own. + */ +function installRaw(bytes, libDir = LIB_DIR) { + fs.mkdirSync(path.dirname(libDir), { recursive: true }); + const stage = fs.mkdtempSync(path.join(path.dirname(libDir), ".tan-install-")); + let preserveStage = false; + try { + const newTree = path.join(stage, "new"); + fs.mkdirSync(newTree); + const exe = path.join(newTree, exeName(process.platform)); + fs.writeFileSync(exe, bytes); + if (process.platform !== "win32") fs.chmodSync(exe, 0o755); + swapIntoPlace(stage, newTree, libDir); + } catch (error) { + preserveStage = Boolean(error && error.preserveStage); + throw error; + } finally { + if (!preserveStage) fs.rmSync(stage, { recursive: true, force: true }); + } +} + async function main() { const target = resolveTarget(); - const ext = process.platform === "win32" ? ".exe" : ""; - const asset = `tan-${target}${ext}`; - const url = `https://github.com/${REPO}/releases/download/${TAG}/${asset}`; - fs.mkdirSync(BINARY_DIR, { recursive: true }); - const binName = process.platform === "win32" ? "tan.exe" : "tan"; - const binPath = path.join(BINARY_DIR, binName); + // Fetch checksums.txt BEFORE choosing an asset name, not just before + // downloading one: it is the release's own manifest of what it published, + // and asking it costs nothing extra since it is fetched unconditionally + // anyway as the integrity source (tan-cli#356; mirrors install.sh's + // digest_for / install.ps1's Get-DigestFor). + const checksumsText = await fetchChecksums(); + const { asset, layout, digest } = selectRelease(checksumsText, process.platform, target); + const url = `https://github.com/${REPO}/releases/download/${TAG}/${asset}`; console.log(`@alplabai/tan: downloading ${asset} (${TAG})…`); const response = await fetch(url); if (!response.ok) { @@ -94,25 +390,25 @@ async function main() { } const bytes = Buffer.from(await response.arrayBuffer()); - // Verify integrity BEFORE writing an executable to disk. Fail closed: a - // missing checksums.txt, a missing entry, or a digest mismatch aborts the - // install rather than running an unverified binary. - await verifyChecksum(bytes, asset); - - // Raw binary asset — write straight to disk, no archive to extract. - fs.writeFileSync(binPath, bytes); + // Verify integrity BEFORE unpacking/installing anything. Fail closed: the + // digest already came from checksums.txt above; a mismatch here aborts + // rather than extracting — let alone running — an unverified binary. + verifyDigest(bytes, asset, digest); - if (process.platform !== "win32") { - fs.chmodSync(binPath, 0o755); + if (layout === "archive") { + installArchive(bytes, asset); + } else { + installRaw(bytes); } + console.log(`@alplabai/tan: installed ${asset} → ${LIB_DIR} (launcher: bin/tan.js).`); } /** - * Fetch the release's checksums.txt and verify `bytes` against the pinned - * SHA-256 for `asset`. Throws (fails closed) on any fetch error, missing - * entry, or mismatch — the caller aborts the install. + * Fetch the release's checksums.txt as text. Throws (fails closed) on any + * fetch error — the caller aborts the install rather than guessing an asset + * shape or running anything unverified. */ -async function verifyChecksum(bytes, asset) { +async function fetchChecksums() { const url = `https://github.com/${REPO}/releases/download/${TAG}/checksums.txt`; const response = await fetch(url); if (!response.ok) { @@ -120,12 +416,41 @@ async function verifyChecksum(bytes, asset) { `@alplabai/tan: could not fetch checksums.txt (HTTP ${response.status}) from ${url} — refusing to run an unverified binary.`, ); } - const expected = parseChecksum(await response.text(), asset); - if (!expected) { - throw new Error( - `@alplabai/tan: no SHA-256 entry for ${asset} in checksums.txt — refusing to run an unverified binary.`, - ); - } + return response.text(); +} + +/** + * Which asset `TAG` actually publishes for `platform`/`triple`, and its + * pinned digest — asked of `checksumsText` itself rather than assumed from + * this shim's own version (tan-cli#356; the bug this whole item exists to + * fix). Archive name first (the shape from v0.5.0), the raw name as fallback + * (every tag up to and including v0.5.0-rc4) — the same order install.sh's + * `digest_for` / install.ps1's `Get-DigestFor` try them in, so a release + * carrying both installs in the current shape and the three consumers cannot + * disagree about which one a given tag has. + * + * Throws, naming BOTH candidate names, if checksums.txt lists neither: that + * is either "no asset for this platform" or "the release shipped an asset and + * forgot to check-sum it", and only the release page can tell them apart — + * see docs/release-contract.md's "Which shape a release publishes". + */ +function selectRelease(checksumsText, platform, triple) { + const archive = assetName(platform, triple); + let digest = parseChecksum(checksumsText, archive); + if (digest) return { asset: archive, layout: "archive", digest }; + + const raw = rawAssetName(platform, triple); + digest = parseChecksum(checksumsText, raw); + if (digest) return { asset: raw, layout: "raw", digest }; + + throw new Error( + `@alplabai/tan: ${TAG} lists no asset for ${triple} in its checksums.txt — neither ${archive} nor ${raw}. ` + + `Check what ${TAG} publishes: https://github.com/${REPO}/releases — refusing to install; there is nothing here to verify against.`, + ); +} + +/** Verify `bytes`' SHA-256 against `expected`. Throws (fails closed) on a mismatch. */ +function verifyDigest(bytes, asset, expected) { const actual = crypto.createHash("sha256").update(bytes).digest("hex"); if (actual !== expected) { throw new Error( @@ -150,9 +475,10 @@ function parseChecksum(text, asset) { } // `require.main === module` so the mapping can be imported by -// test/libc-mapping.test.js without DOWNLOADING a binary as a side effect of -// being read. Without the guard the pin test would have to re-parse this file as -// text, i.e. test a copy of the table rather than the table. +// test/libc-mapping.test.js — and the launcher path by bin/tan.js — without +// DOWNLOADING a binary as a side effect of being read. Without the guard the +// pin test would have to re-parse this file as text, i.e. test a copy of the +// table rather than the table. if (require.main === module) { main().catch((error) => { console.error(error && error.message ? error.message : String(error)); @@ -160,4 +486,19 @@ if (require.main === module) { }); } -module.exports = { TARGETS, resolveTarget, parseChecksum }; +module.exports = { + TARGETS, + ARCHIVE_ROOT, + LIB_DIR, + resolveTarget, + assetName, + rawAssetName, + exeName, + tarBin, + assertSafeEntries, + unpackArchive, + installArchive, + installRaw, + selectRelease, + parseChecksum, +}; diff --git a/npm-shim/test/libc-mapping.test.js b/npm-shim/test/libc-mapping.test.js index bcf29ddf..753d1e80 100644 --- a/npm-shim/test/libc-mapping.test.js +++ b/npm-shim/test/libc-mapping.test.js @@ -29,13 +29,160 @@ // The FILE, not the directory: on node 26 `--test ` resolves the argument as // a module and dies with MODULE_NOT_FOUND, so the directory form silently depends // on the runner's version. +// +// tan-cli#362 widened the same drift class from the TRIPLE to the whole asset: +// the release switched (from v0.5.0, NOT CUT YET) to `.zip`/`.tar.gz` +// archives of a `--onedir` freeze (#349), and this shim started asking for +// ONLY that archive name — a name no tag published today carries, since +// every tag so far, including v0.5.0-rc4, still ships the raw +// `tan-[.exe]` this shim used to ask for. So the asset-name and +// archive-layout tests below live beside the triple ones, against the same +// contract doc — .github/workflows/ci.yml runs this file by name, so a +// second file would not be run at all. const assert = require("node:assert/strict"); const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); +const { execFileSync } = require("node:child_process"); const { test } = require("node:test"); const REPO_ROOT = path.join(__dirname, "..", ".."); -const { TARGETS } = require("../postinstall.js"); +const { + TARGETS, + ARCHIVE_ROOT, + LIB_DIR, + assetName, + rawAssetName, + exeName, + tarBin, + assertSafeEntries, + unpackArchive, + installArchive, + installRaw, + selectRelease, +} = require("../postinstall.js"); + +/** + * A stand-in for a release archive: `tan/{tan[.exe], _internal/…}`, gzipped + * with the system `tar` — the same archiver the shim unpacks with. Returns its + * bytes. `marker` goes into the executable so a test can tell two builds apart. + */ +function fakeReleaseArchive(marker) { + const src = fs.mkdtempSync(path.join(os.tmpdir(), "tan-shim-fixture-")); + fs.mkdirSync(path.join(src, ARCHIVE_ROOT, "_internal"), { recursive: true }); + fs.writeFileSync(path.join(src, ARCHIVE_ROOT, exeName(process.platform)), `${marker}\n`); + fs.writeFileSync(path.join(src, ARCHIVE_ROOT, "_internal", "base_library.zip"), "runtime\n"); + // Relative names with `cwd`, never an absolute path: MSYS/Git-Bash GNU tar + // reads `C:\...` as a REMOTE host spec (`Cannot connect to C:`), so an + // absolute argument here would fail on exactly the platform this repo is + // developed on. + execFileSync("tar", ["-czf", "tan.tar.gz", ARCHIVE_ROOT], { cwd: src }); + const bytes = fs.readFileSync(path.join(src, "tan.tar.gz")); + fs.rmSync(src, { recursive: true, force: true }); + return bytes; +} + +/** + * CRC-32 (the zip local/central-directory checksum field), bit-by-bit -- + * fine for the few hundred bytes these fixtures are. No table, no + * dependency: this and `buildZip` below exist because Node's stdlib has no + * zip WRITER (only `zlib`'s raw deflate, which is a compression algorithm, + * not a container format), and this package -- postinstall.js included -- + * deliberately carries none either (see its header comment). A STORED + * (uncompressed) entry needs no compression codec at all, only this. + */ +function crc32(buf) { + let crc = ~0; + for (const byte of buf) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return ~crc >>> 0; +} + +/** + * A genuine ZIP archive (PK local file headers + central directory + EOCD, + * STORED/uncompressed entries) built from raw bytes, not by renaming a + * `.tar.gz` or shelling out to a zip-capable `tar`. `entries` is + * `[{name, data: Buffer}]`. + * + * Exists for exactly one reason: a Windows e2e that names a `.tar.gz` + * `foo.zip` also "passes" -- bsdtar CONTENT-SNIFFS, so it reads the gzip + * stream anyway -- which proves the shim's Windows branch reaches `tar`, not + * that `tar` can read a real `shutil.make_archive(..., format="zip")` + * archive (tan-cli#362's adversarial review). Hand-rolling the container + * format is the only way to get PK bytes on disk without either a new + * dependency or a zip-writer this host may not have. + */ +function buildZip(entries) { + const localParts = []; + const centralParts = []; + let offset = 0; + for (const { name, data } of entries) { + const nameBuf = Buffer.from(name, "utf8"); + const crc = crc32(data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); // local file header signature + local.writeUInt16LE(20, 4); // version needed to extract + local.writeUInt16LE(0, 6); // general purpose bit flag + local.writeUInt16LE(0, 8); // compression method: 0 = stored + local.writeUInt16LE(0, 10); // last mod file time + local.writeUInt16LE(0x0021, 12); // last mod file date: 1980-01-01 (DOS epoch) + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); // compressed size == uncompressed (stored) + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); // extra field length + localParts.push(local, nameBuf, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); // central directory file header signature + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); // version needed to extract + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(0, 12); + central.writeUInt16LE(0x0021, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt16LE(0, 30); // extra field length + central.writeUInt16LE(0, 32); // file comment length + central.writeUInt16LE(0, 34); // disk number start + central.writeUInt16LE(0, 36); // internal file attributes + central.writeUInt32LE(0, 38); // external file attributes + central.writeUInt32LE(offset, 42); // offset of local header + centralParts.push(central, nameBuf); + + offset += 30 + nameBuf.length + data.length; + } + const centralDir = Buffer.concat(centralParts); + const centralStart = offset; + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); // end of central directory signature + eocd.writeUInt16LE(0, 4); // this disk + eocd.writeUInt16LE(0, 6); // disk with central directory start + eocd.writeUInt16LE(entries.length, 8); // records on this disk + eocd.writeUInt16LE(entries.length, 10); // total records + eocd.writeUInt32LE(centralDir.length, 12); + eocd.writeUInt32LE(centralStart, 16); + eocd.writeUInt16LE(0, 20); // comment length + + return Buffer.concat([...localParts, centralDir, eocd]); +} + +/** A stand-in for a release ZIP: the same `tan/{tan[.exe], _internal/…}` shape `fakeReleaseArchive` builds, as a genuine zip. */ +function fakeReleaseZip(marker) { + return buildZip([ + { name: `${ARCHIVE_ROOT}/${exeName(process.platform)}`, data: Buffer.from(`${marker}\n`) }, + { name: `${ARCHIVE_ROOT}/_internal/base_library.zip`, data: Buffer.from("runtime\n") }, + ]); +} /** The "Targets published" table as `{ "/": [triple, asset] }`. */ function contractTargets() { @@ -110,7 +257,369 @@ test("every asset the shim can name is one the release actually publishes", () = const published = new Set(Object.values(contract).map(([, asset]) => asset)); for (const [key, triple] of Object.entries(TARGETS)) { - const asset = `tan-${triple}${key.startsWith("win32/") ? ".exe" : ""}`; + // The shim's own `assetName`, never a copy of its rule composed here: the + // #362 bug WAS the composition (`tan-[.exe]`), so a test that + // re-composes the name can only ever agree with whichever version of the + // rule it was written beside. + const asset = assetName(key.split("/")[0], triple); assert.ok(published.has(asset), `${key} -> ${asset} is not in the published asset table`); } }); + +test("the shim names the contract's asset for every published target", () => { + // Stronger than the membership check above, which a shim that served every + // host the SAME published asset would still pass. tan-cli#362: the release + // WILL ship `.zip` (Windows) / `.tar.gz` (Unix) archives of a --onedir + // freeze from v0.5.0 -- the transition tag, not cut yet (#349) -- and this + // shim's ARCHIVE name composer (`assetName`) must agree with the contract + // doc's archive column even though no tag today publishes that shape yet + // (see `selectRelease` in postinstall.js for the fallback that makes a + // pre-v0.5.0 tag, e.g. v0.5.0-rc4, install anyway). + const contract = contractTargets(); + const expected = Object.fromEntries( + Object.entries(contract).map(([key, [, asset]]) => [key, asset]), + ); + const actual = Object.fromEntries( + Object.entries(TARGETS).map(([key, triple]) => [key, assetName(key.split("/")[0], triple)]), + ); + + assert.deepEqual(actual, expected); +}); + +test("assetName (the ARCHIVE composer) never emits a raw-binary asset name", () => { + // The failure mode has no error of its own: a raw-binary name is a plain + // HTTP 404 at download time against a v0.5.0+ tag, which reads like a + // network problem rather than a naming bug. Pin the SHAPE so the next + // reader cannot reintroduce it by editing one branch. This is a claim about + // `assetName` specifically -- the shim's ACTUAL install picks between this + // and `rawAssetName` per release via `selectRelease` (tan-cli#356), tested + // separately below. + for (const [key, triple] of Object.entries(TARGETS)) { + const asset = assetName(key.split("/")[0], triple); + assert.match( + asset, + /\.(zip|tar\.gz)$/, + `${key} -> ${asset} is a raw-binary asset name; assetName() must compose the archive shape only (#349)`, + ); + } + assert.equal(assetName("win32", "x86_64-pc-windows-msvc"), "tan-x86_64-pc-windows-msvc.zip"); + assert.equal( + assetName("linux", "x86_64-unknown-linux-gnu"), + "tan-x86_64-unknown-linux-gnu.tar.gz", + ); +}); + +test("the shim unpacks the archive layout the contract documents", () => { + const doc = fs + .readFileSync(path.join(REPO_ROOT, "docs", "release-contract.md"), "utf8") + .replace(/\s+/g, " "); + + // The doc's sentence and the shim's constants have to say the same thing: + // one top-level `tan/`, holding the executable AND `_internal/`. The + // executable does not run without that sibling, so "which entry do I keep" + // is not a free choice for a consumer. + assert.match(doc, /one top-level entry is `tan\/`/); + assert.match(doc, /`_internal\/`/); + assert.equal(ARCHIVE_ROOT, "tan"); + assert.equal(exeName("win32"), "tan.exe"); + assert.equal(exeName("linux"), "tan"); + assert.equal(exeName("darwin"), "tan"); + // The launcher (`bin/tan.js`, this package's `bin` entry) imports LIB_DIR + // from the installer, so the stable path is single-sourced; assert it still + // sits inside the package rather than somewhere npm will not ship or clean. + assert.equal(path.basename(LIB_DIR), "tan-cli-lib"); + assert.equal(path.dirname(LIB_DIR), path.join(REPO_ROOT, "npm-shim")); +}); + +test("extraction refuses anything that escapes the destination", () => { + // Unit-level on purpose: `tar` REWRITES these names at create time (GNU tar + // strips a leading `../` when packing), so an archive fixture cannot carry + // them without a tar-specific escape hatch that differs between GNU tar and + // bsdtar. The listing is what the check consumes, so drive the listing. + const layout = ["tan/", "tan/tan", "tan/_internal/", "tan/_internal/base_library.zip"]; + assert.doesNotThrow(() => assertSafeEntries(layout)); + assert.doesNotThrow(() => assertSafeEntries(layout.map((e) => `./${e}`))); + + for (const evil of [ + "../evil", + "tan/../../evil", + "tan/_internal/../../../evil", + "/etc/cron.d/evil", // absolute, POSIX — must be rejected while running on Windows + "C:\\Windows\\System32\\evil.dll", // absolute, Windows — rejected while running on POSIX + "\\\\server\\share\\evil", + "not-tan/tan", // a second top-level tree is a layout change, not our archive + ]) { + assert.throws( + () => assertSafeEntries([evil]), + /refusing to extract|unexpected entry/, + `${evil} was accepted`, + ); + } +}); + +test("a real archive unpacks to the launcher's executable plus its runtime", () => { + // End-to-end over the system archiver, which is the whole reason this shim + // has no dependency: node's stdlib reads neither tar nor zip. + const dest = fs.mkdtempSync(path.join(os.tmpdir(), "tan-shim-dest-")); + try { + const archive = path.join(dest, "tan-x86_64-unknown-linux-gnu.tar.gz"); + fs.writeFileSync(archive, fakeReleaseArchive("build-1")); + + const unpacked = unpackArchive(archive, dest); + assert.equal(unpacked, path.join(dest, ARCHIVE_ROOT, exeName(process.platform))); + assert.ok(fs.existsSync(unpacked), "executable missing after extraction"); + assert.ok( + fs.existsSync(path.join(dest, ARCHIVE_ROOT, "_internal", "base_library.zip")), + "_internal/ runtime missing after extraction — the executable cannot run without it", + ); + } finally { + fs.rmSync(dest, { recursive: true, force: true }); + } +}); + +test("an archive without the freeze's runtime is rejected, not installed", () => { + // A `tan` with no `_internal/` starts and dies inside PyInstaller's loader, + // which reads as a broken binary rather than a broken download. Catch it + // while the cause is still on screen. + const src = fs.mkdtempSync(path.join(os.tmpdir(), "tan-shim-broken-")); + const dest = fs.mkdtempSync(path.join(os.tmpdir(), "tan-shim-broken-dest-")); + try { + fs.mkdirSync(path.join(src, ARCHIVE_ROOT)); + fs.writeFileSync(path.join(src, ARCHIVE_ROOT, exeName(process.platform)), "#!/bin/sh\n"); + execFileSync("tar", ["-czf", "tan.tar.gz", ARCHIVE_ROOT], { cwd: src }); + + assert.throws(() => unpackArchive(path.join(src, "tan.tar.gz"), dest), /_internal/); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + fs.rmSync(dest, { recursive: true, force: true }); + } +}); + +test("install lands the whole tree at the launcher's path, and replaces it on upgrade", () => { + // Installs into a temp dir rather than the package's real `tan-cli-lib/`: + // `installArchive` takes the destination precisely so this can be exercised + // without a ~40 MB freeze appearing in a working tree. + const home = fs.mkdtempSync(path.join(os.tmpdir(), "tan-shim-install-")); + const libDir = path.join(home, "tan-cli-lib"); + const exe = path.join(libDir, exeName(process.platform)); + try { + installArchive(fakeReleaseArchive("build-1"), "tan-x86_64-apple-darwin.tar.gz", libDir); + assert.equal(fs.readFileSync(exe, "utf8").trim(), "build-1"); + assert.ok(fs.existsSync(path.join(libDir, "_internal")), "runtime not installed beside the exe"); + + // The upgrade path is where a delete-then-extract implementation loses the + // working install: the second install must REPLACE the tree, not merge + // into it or fail on the existing directory. + installArchive(fakeReleaseArchive("build-2"), "tan-x86_64-apple-darwin.tar.gz", libDir); + assert.equal(fs.readFileSync(exe, "utf8").trim(), "build-2"); + + // Nothing left behind: no staging dir, no `tan/` beside `tan-cli-lib/`, + // no downloaded archive. `tan-cli-lib` is the only thing installed. + assert.deepEqual(fs.readdirSync(home), ["tan-cli-lib"]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// tan-cli#356 (shape selection) -- item 1 of the adversarial review that +// found the shim 404ing at its own pinned version, v0.5.0-rc4: `assetName` +// alone can only ever ask for the archive shape, which no tag published today +// carries. `selectRelease` is what actually decides, from checksums.txt, the +// same way install.sh's `digest_for` / install.ps1's `Get-DigestFor` do. +// --------------------------------------------------------------------------- + +test("selectRelease prefers the archive asset, falls back to the raw one, per checksums.txt", () => { + const platform = "linux"; + const triple = "x86_64-unknown-linux-gnu"; + const archive = assetName(platform, triple); + const raw = rawAssetName(platform, triple); + const archiveDigest = "a".repeat(64); + const rawDigest = "b".repeat(64); + + // v0.5.0-rc4 and earlier: checksums.txt lists only the raw name. + let result = selectRelease(`${rawDigest} ${raw}\n`, platform, triple); + assert.deepEqual(result, { asset: raw, layout: "raw", digest: rawDigest }); + + // v0.5.0 and later: both names could in principle appear (a release that + // ships an archive still names the triple the same way); archive wins, the + // same order install.sh's digest_for tries them in. + result = selectRelease(`${archiveDigest} ${archive}\n${rawDigest} ${raw}\n`, platform, triple); + assert.deepEqual(result, { asset: archive, layout: "archive", digest: archiveDigest }); + + // Neither name listed: refuse, naming BOTH candidates -- not just the one + // this shim happened to try first. + assert.throws( + () => selectRelease("", platform, triple), + (error) => error.message.includes(archive) && error.message.includes(raw), + "error must name both the archive and the raw candidate", + ); +}); + +test("rawAssetName matches install.sh's raw_asset / install.ps1's rawAsset composition", () => { + // Textual cross-check, same style as the Linux-libc test above: a rename on + // either side that the other doesn't follow is exactly the class of drift + // this file exists to catch (tan-cli#356, mirroring #362's triple-drift). + const sh = fs.readFileSync(path.join(REPO_ROOT, "install.sh"), "utf8"); + const ps1 = fs.readFileSync(path.join(REPO_ROOT, "install.ps1"), "utf8"); + assert.match(sh, /raw_asset="tan-\$\{arch_part\}-\$\{os_part\}"/); + assert.match(ps1, /\$rawAsset\s*=\s*"tan-\$archPart-pc-windows-msvc\.exe"/); + // And install.sh tries the archive name before falling back to the raw one + // (`asset="$archive_asset"` ... `asset="$raw_asset"`, in that order) -- + // selectRelease above must try them in the same order. + const archiveIdx = sh.indexOf('asset="$archive_asset"'); + const rawIdx = sh.indexOf('asset="$raw_asset"'); + assert.ok(archiveIdx >= 0 && rawIdx >= 0 && archiveIdx < rawIdx); + + assert.equal(rawAssetName("linux", "x86_64-unknown-linux-gnu"), "tan-x86_64-unknown-linux-gnu"); + assert.equal(rawAssetName("darwin", "aarch64-apple-darwin"), "tan-aarch64-apple-darwin"); + assert.equal(rawAssetName("win32", "x86_64-pc-windows-msvc"), "tan-x86_64-pc-windows-msvc.exe"); +}); + +test("install lands a raw asset directly at the launcher's path (pre-v0.5.0 tags, e.g. v0.5.0-rc4)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "tan-shim-raw-install-")); + const libDir = path.join(home, "tan-cli-lib"); + const exe = path.join(libDir, exeName(process.platform)); + try { + installRaw(Buffer.from("raw-build-1\n"), libDir); + assert.equal(fs.readFileSync(exe, "utf8").trim(), "raw-build-1"); + + // Upgrading a raw install (rc -> rc) must replace it cleanly too. + installRaw(Buffer.from("raw-build-2\n"), libDir); + assert.equal(fs.readFileSync(exe, "utf8").trim(), "raw-build-2"); + assert.deepEqual(fs.readdirSync(home), ["tan-cli-lib"]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("upgrading from a raw install to an archive install replaces it cleanly (v0.5.0-rc4 -> v0.5.0)", () => { + // The real transition a `tan-cli-lib` on disk goes through: today's raw + // pre-release, then the archive shape once v0.5.0 cuts. `swapIntoPlace` + // must not care that the shapes differ. + const home = fs.mkdtempSync(path.join(os.tmpdir(), "tan-shim-shape-upgrade-")); + const libDir = path.join(home, "tan-cli-lib"); + const exe = path.join(libDir, exeName(process.platform)); + try { + installRaw(Buffer.from("rc4-build\n"), libDir); + installArchive(fakeReleaseArchive("final-build"), "tan-x86_64-apple-darwin.tar.gz", libDir); + assert.equal(fs.readFileSync(exe, "utf8").trim(), "final-build"); + assert.ok(fs.existsSync(path.join(libDir, "_internal")), "the archive install must land its runtime too"); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Item 5 of the adversarial review: a failed rollback must not delete the +// only surviving copy of the previous install. +// --------------------------------------------------------------------------- + +test("a failed rollback preserves the previous install instead of deleting it", (t) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "tan-shim-rollback-")); + const libDir = path.join(home, "tan-cli-lib"); + try { + // A previous, working install already in place. + installArchive(fakeReleaseArchive("good-build"), "tan-x86_64-apple-darwin.tar.gz", libDir); + + const originalRename = fs.renameSync.bind(fs); + let call = 0; + t.mock.method(fs, "renameSync", (...args) => { + call += 1; + // call 1: libDir -> stage/previous (moving the old tree aside). Let it + // happen for real, so there is something to protect. + if (call === 1) return originalRename(...args); + // call 2: stage/tan -> libDir (the swap itself). Simulate the target + // being locked, e.g. a running `tan` from the old install still has + // libDir/tan open (the real EBUSY/EPERM this dance exists for). + if (call === 2) throw new Error("simulated EBUSY"); + // call 3: the rollback (stage/previous -> libDir). Simulate it ALSO + // failing -- the case this test exists for. + throw new Error("simulated EPERM (rollback)"); + }); + + let thrown; + try { + installArchive(fakeReleaseArchive("new-build"), "tan-x86_64-apple-darwin.tar.gz", libDir); + } catch (error) { + thrown = error; + } + assert.ok(thrown, "installArchive did not throw when both the swap and the rollback failed"); + assert.match(thrown.message, /could not restore the previous install/); + + // The surviving copy: wherever call 1 actually renamed the old libDir to. + const previousPath = fs.renameSync.mock.calls[0].arguments[1]; + assert.ok( + thrown.message.includes(previousPath), + "the error must name where the surviving install actually is", + ); + assert.ok( + fs.existsSync(previousPath), + "the only surviving copy of the pre-upgrade install was deleted by cleanup", + ); + assert.equal( + fs.readFileSync(path.join(previousPath, exeName(process.platform)), "utf8").trim(), + "good-build", + "the surviving copy is not the pre-upgrade install", + ); + } finally { + t.mock.restoreAll(); + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Item 6 of the adversarial review: a real zip fixture, not a renamed +// tar.gz -- and a cross-check that the shape rule and layout constants agree +// with install.sh's. +// --------------------------------------------------------------------------- + +test("fakeReleaseZip produces a genuine PK zip, not a gzip stream wearing the extension", () => { + // Runs on every platform/CI runner, unlike the extraction test below, which + // needs a zip-capable tar. This is the assertion that would have caught + // #362's own gap directly: a renamed `.tar.gz` starts with gzip's `1f 8b`, + // never with a zip local-file-header signature. + const bytes = fakeReleaseZip("marker"); + assert.deepEqual([...bytes.subarray(0, 4)], [0x50, 0x4b, 0x03, 0x04], "missing PK\\x03\\x04 zip signature"); + assert.notDeepEqual([...bytes.subarray(0, 2)], [0x1f, 0x8b], "fixture is a gzip stream, not a zip"); +}); + +test("a real .zip archive extracts through the exact tar binary the shim resolves", (t) => { + // Deliberately uses `tarBin()` -- the shim's own resolution, System32's + // bsdtar on Windows rather than whatever `tar` PATH turns up first -- not a + // hardcoded binary name, so this exercises the actual codepath a Windows + // install runs, not a stand-in for it. + const bin = tarBin(); + let versionOut = ""; + try { + versionOut = execFileSync(bin, ["--version"], { encoding: "utf8" }); + } catch (error) { + t.skip(`could not run ${bin} --version: ${error.message}`); + return; + } + if (!/bsdtar/i.test(versionOut)) { + // GNU tar -- the Linux CI runner's PATH `tar` -- cannot read zip AT ALL; + // it answers "This does not look like a tar archive". That is a real, + // already-documented limitation (see postinstall.js's `tarBin()` + // comment), not a shim bug: Linux never downloads a `.zip` in + // production, only Windows does, which is exactly why `tarBin()` picks + // bsdtar there specifically. Skip rather than fail or fake a pass. + t.skip(`${bin} is not bsdtar (zip-capable): ${versionOut.split("\n")[0]}`); + return; + } + + const dest = fs.mkdtempSync(path.join(os.tmpdir(), "tan-shim-zip-dest-")); + try { + const archive = path.join(dest, "tan-x86_64-pc-windows-msvc.zip"); + fs.writeFileSync(archive, fakeReleaseZip("zip-1")); + + const unpacked = unpackArchive(archive, dest); + assert.equal(fs.readFileSync(unpacked, "utf8").trim(), "zip-1"); + assert.ok( + fs.existsSync(path.join(dest, ARCHIVE_ROOT, "_internal", "base_library.zip")), + "_internal/ runtime missing after extracting a real zip", + ); + } finally { + fs.rmSync(dest, { recursive: true, force: true }); + } +}); diff --git a/python/scripts/build_binary.sh b/python/scripts/build_binary.sh index a6f362fa..75bfd92f 100755 --- a/python/scripts/build_binary.sh +++ b/python/scripts/build_binary.sh @@ -1,14 +1,32 @@ #!/usr/bin/env bash # SPDX-License-Identifier: Apache-2.0 # -# Build the single-file `tan` executable. +# Build the `tan` executable as a PyInstaller --onedir freeze and archive it. # -# --onefile is REQUIRED, not a preference: the VS Code extension downloads a raw -# binary straight to ONE cached path and has no unpack step anywhere in it -# (alp-sdk-vscode/src/alpCli/service.ts:295 "tan-cli ships a RAW binary per -# target (not an archive)"; download.ts:159-162 writes the response body to the -# destination file; download.ts:124-129 chmods it 0o755). A --onedir artifact -# cannot be consumed by the extension at all. +# --onedir, not --onefile, as of tan-cli#349. --onefile re-extracts its whole +# ~14 MB runtime into a FRESH temp dir on EVERY invocation, and on macOS each +# extracted .dylib is unsigned (the parent's ad-hoc signature does not cover +# extracted copies), so the OS re-verifies every one of them on every launch. +# Measured on the published v0.5.0-rc4 --onefile assets (5 runs of --version): +# macOS arm64 13.25/19.35/19.35/18.58/19.74 s, against git --version's 0.01 s +# on the same host. alp-sdk-vscode's own version probe times out at 3 s +# (vscodeAdapter.ts:1406) and its commandOnPath check at 5 s -- that asset's +# --version TIMED OUT under the extension's own probe, not merely "slow". +# Confirmed locally too (Windows, this host, Python 3.12.10/PyInstaller +# 6.21.0, mean of 5 --version runs): --onefile 0.880 s vs --onedir 0.369 s -- +# a >2x win even on the platform that was never the emergency, since --onedir +# extracts ONCE, at install time, rather than per invocation. +# +# The old rationale here (an --onedir artifact "cannot be consumed by the +# extension at all", because the extension downloaded a raw binary straight to +# one cached path with no unpack step anywhere) is exactly the shape of the +# tan-cli#259 failure this comment used to warn about -- a stale comment +# asserting the opposite of the code. It stopped being true the moment this +# script started emitting an archive instead of a raw binary: the archive +# below IS meant to be unpacked, which install.sh (../install.sh, this repo) +# already does. Unpacking it on the alp-sdk-vscode side is a SEPARATE unit of +# #349, landing independently in that repo -- this script and the archive it +# produces are correct on their own regardless of when that lands. # # PyInstaller is a BUILD-TIME tool only -- deliberately absent from the runtime # dependencies in pyproject.toml. Build from a CLEAN environment holding nothing @@ -59,10 +77,12 @@ # falls back to the SDK's own `-m alp_orchestrate` subprocess and, failing that, # reports a coded `build.plan-unavailable` -- but no release should ship so. # -# The artifact is named `tan` / `tan.exe` here. Release assets carry the Rust -# target triple the extension already hardcodes (service.ts:34-46) -- rename on -# upload, e.g. tan.exe -> tan-x86_64-pc-windows-msvc.exe. PyInstaller cannot -# cross-compile: each of the six targets must be built on its own host/runner. +# The onedir folder is named `tan/` (dist/tan/tan[.exe] + dist/tan/_internal/) +# and the ARCHIVE built from it below is named `tan.zip` / `tan.tar.gz` here. +# Release assets carry the Rust target triple the extension already hardcodes +# (service.ts:34-46) -- rename on upload, e.g. tan.zip -> +# tan-x86_64-pc-windows-msvc.zip. PyInstaller cannot cross-compile: each target +# must be built on its own host/runner. set -euo pipefail cd "$(dirname "$0")/.." @@ -103,12 +123,31 @@ case "${OS:-}" in Windows_NT) ADD_DATA_SEP=';' ;; esac # that let `click` go undeclared until `tests/gates/test_declared_dependencies.py` # existed (see that gate's own docstring). `truststore` needs no equivalent # flag: it carries no data files, only Python + the OS's own verifier APIs. -"${PYTHON:-python}" -m PyInstaller --onefile --name tan --clean --noconfirm \ +"${PYTHON:-python}" -m PyInstaller --onedir --name tan --clean --noconfirm \ --console --distpath dist --workpath .build --specpath .build \ --add-data "../tan/templates/vendored${ADD_DATA_SEP}tan/templates/vendored" \ --collect-data certifi \ --paths . tan/__main__.py +# Archive the onedir folder into the actual release artefact -- one file, so +# `checksums.txt`/the attestation/install.sh all still deal with a single +# thing per target, matching the old raw-binary contract's shape even though +# the payload is now a directory (tan-cli#349). zip on Windows (installers on +# that platform reach for it natively); tar.gz elsewhere, matching every +# existing `.tar.gz`-based download path (curl | tar in getting-started.yml, +# install.sh). shutil.make_archive over `zip`/`tar` as external commands: it +# is stdlib, so it needs nothing this build venv doesn't already have, and it +# behaves identically across the three build OSes. +archive_ext=tar.gz +archive_format=gztar +case "${OS:-}" in Windows_NT) archive_ext=zip; archive_format=zip ;; esac +"${PYTHON:-python}" - "$archive_format" <<'PY' +import shutil +import sys + +shutil.make_archive("dist/tan", sys.argv[1], root_dir="dist", base_dir="tan") +PY + # Fail the BUILD, not merely the test suite, on a dirty interpreter. $PYTHON # stays optional on purpose: an already-activated clean venv should not need # ceremony, and demanding the variable would only prove someone set it, never @@ -136,8 +175,10 @@ if ldd --version 2>&1 | head -1 | grep -qi musl || ls /lib/ld-musl-* >/dev/null libc=musl fi -artifact=dist/tan -[ -f dist/tan.exe ] && artifact=dist/tan.exe +# The ceiling now measures the ARCHIVE, not the onedir folder: that is the one +# file a consumer actually downloads, and a "size of a directory" number would +# depend on the filesystem's block size rather than on what shipped. +artifact="dist/tan.${archive_ext}" size=$(wc -c <"$artifact") if [ "$size" -ge "$max_bytes" ]; then # QUARANTINE, do not merely complain. `exit 1` alone is defeatable by a pipe: diff --git a/python/scripts/verify_binary.sh b/python/scripts/verify_binary.sh index 479035bd..bf806931 100755 --- a/python/scripts/verify_binary.sh +++ b/python/scripts/verify_binary.sh @@ -6,8 +6,11 @@ # sh scripts/verify_binary.sh # # A binary that starts is not a binary that works. Each check below is here -# because it is a real failure mode of a PyInstaller onefile build, and every -# one of them was hit while establishing this path: +# because it is a real failure mode of a PyInstaller freeze, and every one of +# them was hit while establishing this path. From tan-cli#349 the freeze is +# --onedir, not --onefile: $BIN is the executable inside the onedir tree +# (e.g. dist/tan/tan or dist/tan/tan.exe), with a `_internal/` sibling +# directory that check 5/5 below depends on. # # 1. --version -- import graph resolves at all. `python/tan/cli.py` # imports `click.testing`, which typer 0.27 no @@ -49,6 +52,31 @@ SDK=${2:?usage: verify_binary.sh } fail() { echo "FAIL: $1" >&2; exit 1; } +# tan-cli#361: checks 3-5 run from a throwaway project directory (`cd "$work"` +# below), so a RELATIVE argument -- which is exactly what the usage line above +# documents (`dist/tan/tan`) -- stops resolving the instant that cd happens. +# `sh scripts/verify_binary.sh ./dist/tan/tan /tmp/sdk` passed 1/5 and 2/5 and +# then died with `./dist/tan/tan: not found` at 3/5. BOTH arguments carry the +# bug, not just the one the issue named: $BIN is re-invoked in 3/5 and 4/5 and +# read from disk in 5/5, and $SDK is handed to the binary as `--sdk-root` in +# 4/5, where the binary resolves it against ITS cwd -- $work, not the caller's. +# Every CI call site passed `$PWD/...`, which is why CI could never see it. +# +# Resolved HERE, once, before the first cd -- not lazily at each use site, so +# whoever adds a check 6 does not have to remember. cd-into-dirname then `pwd`, +# recombined with `basename`, because the one-liners do not exist everywhere +# this runs: `readlink -f` is GNU-only (BSD readlink has no -f at all) and +# `realpath` is absent from a stock macOS, while this script must also survive +# busybox ash, dash, macOS bash 3.2 and Git Bash. `${bindir%/}` so a binary +# sitting at the filesystem root comes back as `/tan` and not `//tan` -- a +# leading `//` is implementation-defined in POSIX and Git Bash reads it as a +# UNC share. +[ -f "$BIN" ] || fail "no such binary: $BIN" +[ -d "$SDK" ] || fail "no such alp-sdk checkout: $SDK" +bindir=$(cd -- "$(dirname -- "$BIN")" && pwd) +BIN="${bindir%/}/$(basename -- "$BIN")" +SDK=$(cd -- "$SDK" && pwd) + echo "== 1/5 $BIN --version" "$BIN" --version || fail "--version exited non-zero" @@ -102,14 +130,36 @@ grep -q '"ok":true' gen.json || fail "generate envelope not ok: $(cat gen.json)" [ -s ./out/alp.conf ] || fail "generate wrote no --output file" grep -q "^CONFIG_" ./out/alp.conf || fail "emitted file carries no CONFIG_ lines" -# STRUCTURAL, not a live network call. PyInstaller's onefile archive stores -# each embedded file/module's ORIGINAL NAME as plain ASCII in its TOC, right -# next to the (possibly zlib-compressed) entry it names -- grepping the raw -# executable for these names is a real proof of what got bundled, verified -# against a real freeze while writing this check (`grep -c cacert.pem -# dist/tan.exe` -> 1; `grep -o 'truststore[a-z._]*' dist/tan.exe` -> all four -# platform backends, on every OS this is built on -- `truststore/__init__.py` -# imports them unconditionally and lets `ssl` pick the live one). +# STRUCTURAL, not a live network call. Two different proofs for two different +# kinds of bundled thing, because tan-cli#349 (--onedir) split them apart: +# +# * `truststore` is pure-Python module CODE, no data files. PyInstaller +# still compresses pure-Python module code into a PYZ archive embedded +# INSIDE the executable itself, under --onedir exactly as it did under +# --onefile -- externalising to `_internal/` only applies to DATA/BINARY/ +# EXTENSION entries, not the PYZ. The PYZ's directory table stores each +# module's ORIGINAL NAME as plain ASCII right next to its (possibly +# zlib-compressed) entry, so grepping the executable for the name is a +# real proof of what got bundled -- verified against a real --onedir +# freeze while fixing this check for #349: `grep -o +# 'truststore[a-z._]*' dist/tan/tan.exe` still finds all four platform +# backends (`truststore/__init__.py` imports them unconditionally and +# lets `ssl` pick the live one). UNCHANGED by #349. +# +# * `certifi`'s `cacert.pem` is a DATA file (collected via `--collect-data +# certifi` in build_binary.sh, not import-graph-reachable code), and DATA +# files are exactly what --onedir stops embedding in the executable: +# externalising them to disk ONCE at build time, instead of re-extracting +# them from inside the exe on every launch, is the entire point of +# --onedir. So `grep cacert.pem` on the executable now finds NOTHING -- +# measured on a real --onedir freeze, `grep -c cacert.pem dist/tan/tan.exe` +# -> 0 -- while the same freeze passed under --onefile. Grepping the exe +# here would pass or fail for the wrong reason: not proof of a real defect, +# proof of the wrong file. The real bundled bytes are a loose file at +# `_internal/certifi/cacert.pem`, a sibling of $BIN, so this check now +# asserts on THAT path directly (existence + non-empty, not a byte-content +# grep -- a PEM bundle's own content has no reason to contain the literal +# string "cacert.pem"). # # Chosen over `"$BIN" sdk list --online` against the real endpoint (the fix # the #304 issue itself suggested) because it does NOT discriminate on every @@ -119,19 +169,20 @@ grep -q "^CONFIG_" ./out/alp.conf || fail "emitted file carries no CONFIG_ lines # `create_default_context()`, a fallback macOS and Linux do not have, which is # exactly why the shipped defect was a macOS asset. A live-network check on # this platform would pass green on a build missing the fix entirely. This -# check has no such blind spot: it looks for the SAME bundled names on every +# check has no such blind spot: it looks for the SAME bundled things on every # OS, so it goes red the moment either mechanism drops out of the freeze, # consistently, and needs no network to do it. # # Proves: the CA bundle `certifi.where()` resolves at runtime, and -# `truststore`'s platform backends, are physically in this archive. Does NOT +# `truststore`'s platform backends, are physically in this freeze. Does NOT # prove: that `ssl.create_default_context()` actually verifies a real # certificate chain at runtime, or that the endpoint is reachable -- #304 was # reachable-but-untrusted, not unreachable, so only a live call proves THAT, # and this check trades it for one that cannot be masked by which OS built it. echo "== 5/5 CA trust anchors are bundled (tan-cli#304)" -grep -q "cacert.pem" "$BIN" || - fail "no certifi cacert.pem embedded -- check --collect-data certifi in build_binary.sh (tan-cli#304 would recur)" +CA_BUNDLE="$(dirname "$BIN")/_internal/certifi/cacert.pem" +[ -s "$CA_BUNDLE" ] || + fail "no certifi CA bundle at $CA_BUNDLE -- check --collect-data certifi in build_binary.sh (tan-cli#304 would recur)" grep -q "truststore" "$BIN" || fail "no truststore module embedded -- tan/net.py's preferred CA mechanism is missing from this freeze (tan-cli#304 would recur)" diff --git a/python/tan/cli.py b/python/tan/cli.py index 9bc31fb5..f7f70b5a 100644 --- a/python/tan/cli.py +++ b/python/tan/cli.py @@ -26,17 +26,14 @@ from tan.commands.build_cmd import build from tan.commands.clean_cmd import clean from tan.commands.debug_config_cmd import debug_config -from tan.commands.deferred_cmd import ( - DEFERRED_CONTEXT_SETTINGS, - DEFERRED_VERBS, - completion, - diff, - inspect, - pinmux, - scaffold, - support_bundle, - trace, -) +from tan.commands.completion_cmd import completion +from tan.commands.diff_cmd import diff +from tan.commands.inspect_cmd import inspect +from tan.commands.pinmux_cmd import pinmux +from tan.commands.scaffold_cmd import scaffold +from tan.commands.support_bundle_cmd import support_bundle +from tan.commands.trace_cmd import trace +from tan.commands.deferred_cmd import DEFERRED_CONTEXT_SETTINGS from tan.commands.doctor_cmd import doctor from tan.commands.examples_cmd import examples from tan.commands.explain_cmd import explain @@ -56,6 +53,7 @@ from tan.commands.size_cmd import size from tan.commands.validate_cmd import validate from tan.commands.west_forward_cmd import FORWARD_CONTEXT_SETTINGS, lock, migrate, quality +from tan.core.global_flags import GLOBAL_FLAG_ARITY from tan.envelope import ( Envelope, Issue, @@ -78,9 +76,9 @@ app.command("bootstrap")(bootstrap) app.command("build")(build) app.command("clean")(clean) -app.command("completion", context_settings=DEFERRED_CONTEXT_SETTINGS)(completion) +app.command("completion")(completion) app.command("debug-config")(debug_config) -app.command("diff", context_settings=DEFERRED_CONTEXT_SETTINGS)(diff) +app.command("diff")(diff) app.command("doctor")(doctor) app.command("examples")(examples) app.command("explain")(explain) @@ -89,23 +87,23 @@ app.command("generate")(generate) app.command("image")(image) app.command("init")(init) -app.command("inspect", context_settings=DEFERRED_CONTEXT_SETTINGS)(inspect) +app.command("inspect")(inspect) app.command("kconfig")(kconfig) app.command("lock", context_settings=FORWARD_CONTEXT_SETTINGS)(lock) app.command("migrate", context_settings=FORWARD_CONTEXT_SETTINGS)(migrate) app.command("model")(model) app.command("monitor")(monitor) app.command("new-som")(new_som) -app.command("pinmux", context_settings=DEFERRED_CONTEXT_SETTINGS)(pinmux) +app.command("pinmux")(pinmux) app.command("presets")(presets) app.command("quality", context_settings=FORWARD_CONTEXT_SETTINGS)(quality) app.command("renode")(renode) app.command("run")(run) -app.command("scaffold", context_settings=DEFERRED_CONTEXT_SETTINGS)(scaffold) +app.command("scaffold")(scaffold) app.command("sdk")(sdk) app.command("size")(size) -app.command("support-bundle", context_settings=DEFERRED_CONTEXT_SETTINGS)(support_bundle) -app.command("trace", context_settings=DEFERRED_CONTEXT_SETTINGS)(trace) +app.command("support-bundle")(support_bundle) +app.command("trace")(trace) app.command("validate")(validate) #: Every registered subcommand name -- must track the `app.command(...)` calls @@ -134,18 +132,14 @@ #: `--version` is not here either -- it lives on `Cli` directly in clap, not #: `GlobalArgs`, and is root-only on both sides already. #: Value: the flag's arity (1 = takes a value, 0 = boolean). -_GLOBAL_FLAG_ARITY: dict[str, int] = { - "--project": 1, - "--board-yaml": 1, - "--sdk-root": 1, - "--target": 1, - "--all": 0, - "--verbose": 0, - "--quiet": 0, - "--no-color": 0, - "--non-interactive": 0, - "--ci": 0, -} +#: +#: Imported from `tan.core.global_flags` rather than hand-copied a second +#: time (tan-cli#261): that module is also what +#: `tan.core.global_flags.accept_global_flags` reads to decide which flags a +#: command is missing, so this reorder table and the per-command injection +#: list cannot drift apart the way two independent hand-written copies of +#: clap's `GlobalArgs` field list eventually would. +_GLOBAL_FLAG_ARITY: dict[str, int] = GLOBAL_FLAG_ARITY def _reorder_global_flags(argv: list[str]) -> list[str]: @@ -304,7 +298,25 @@ def _emit_help_envelope(argv: list[str]) -> int: #: that module exists to eliminate. Each stub reads `ctx.obj["format"]` (see #: `deferred_cmd.py`). _HONOURS_ROOT_FORMAT = frozenset( - {"debug-config", "flash", "image", "size", "faultdecode", *DEFERRED_VERBS} + { + "debug-config", + "flash", + "image", + "size", + "faultdecode", + # tan-cli#260's seven, listed by name since they were ported and + # `deferred_cmd.DEFERRED_VERBS` no longer exists. Every one of them + # reads `ctx.obj["format"]` the way `debug_config_cmd.py` does, so + # every one belongs here -- the set is unchanged from when the tuple + # supplied it, only spelled out. + "completion", + "diff", + "inspect", + "pinmux", + "scaffold", + "support-bundle", + "trace", + } ) @@ -554,6 +566,28 @@ def getvalue(self) -> str: return self._buffer.getvalue() +def _reconfigure_stdio() -> None: + """Force UTF-8, LF-only stdout/stderr, once, at the process boundary. + + Every command downstream just `print()`s -- correctness here is what + makes that safe. A normal Windows `TextIOWrapper` translates a written + `\\n` to `\\r\\n` and encodes with the process's ANSI code page, neither of + which the oracle's `serde_json`/`println!` output does. Both are visible + on stdout, not just in theory: measured, `tan completion --shell bash` + was 3975 bytes with 108 `\\r` where the oracle's was 3867 bytes with zero + -- and the emitted script is a hard syntax error when sourced in a strict + bash (`syntax error near unexpected token $'{\\r''`); `clean --format + json` and a bare `--format json` both ended `\\r\\n` too, so this is a + process-wide stdout-newline defect, not a completion-specific one. A + frozen/piped stream may not implement `.reconfigure()` (e.g. a test + harness's in-memory buffer) -- `hasattr` skips those rather than raising, + since the fix only matters for the real console/pipe case it targets. + """ + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", newline="\n") + + def main() -> None: """Process entrypoint. @@ -575,6 +609,7 @@ def main() -> None: missing stdout envelope when the exit signals failure under `--format json`. """ + _reconfigure_stdio() argv = _reorder_global_flags(sys.argv[1:]) sys.argv = [sys.argv[0], *argv] json_mode = _wants_json(argv) diff --git a/python/tan/commands/bootstrap_cmd.py b/python/tan/commands/bootstrap_cmd.py index 07513d58..231adaa6 100644 --- a/python/tan/commands/bootstrap_cmd.py +++ b/python/tan/commands/bootstrap_cmd.py @@ -124,6 +124,7 @@ yocto_only_refusal, zephyr_requirements_hint, ) +from tan.core.global_flags import accept_global_flags from tan.core.scaffold import sdk_pointer_json from tan.envelope import Envelope, Issue, Project, SdkInfo, emit from tan.exit_codes import ExitCode @@ -2648,3 +2649,10 @@ def bootstrap( for line in outcome.text: _eprint(line) raise typer.Exit(int(outcome.exit_code)) + + +# tan-cli#261: adds the two oracle `GlobalArgs` flags this command was still +# missing (`--all`, `--target`) on top of the five already declared above +# (`--verbose`/`--quiet`/`--no-color`/`--non-interactive`/`--ci`, all +# `hidden=True` and dropped the same way); see `tan.core.global_flags`. +bootstrap = accept_global_flags(bootstrap) diff --git a/python/tan/commands/build/execute.py b/python/tan/commands/build/execute.py index 87a0932b..332492eb 100644 --- a/python/tan/commands/build/execute.py +++ b/python/tan/commands/build/execute.py @@ -6,7 +6,7 @@ mod.rs`, trimmed to this port's current scope: no `tan build --pristine` manual override (`force_pristine` in the Rust oracle -- this port's `build` command has no `--pristine` flag yet, so the automatic stamp comparison is -the only path that can ever fire), and no Zephyr-boilerplate-loaded guard. +the only path that can ever fire). What IS ported: the unknown-backend / null-command / unsafe-cwd / missing-tool skip-vs-fail policy and dispatch order, the build-dir-must-exist-before-the- tool-runs precondition, the `tool == "west"` rewrite to the workspace venv's @@ -17,8 +17,12 @@ `tan.core.venv`), the sdk-switch-pristine guard (issue #52: wipe a slice's build dir before dispatch when it was configured against a different SDK root than this run resolved, then re-stamp it -- see -[_maybe_pristine_stale_sdk_build_dir]), and (see [`last_manifest_write`]) the -post-build `system-manifest.yaml` write. +[_maybe_pristine_stale_sdk_build_dir]), (tan-cli#309, upstream tan-cli #97) +the Zephyr-boilerplate-loaded guard -- an `os: zephyr` slice that exits 0 +without ever loading Zephyr's CMake boilerplate (`tan.commands.build. +manifest.zephyr_boilerplate_loaded`) is reported `failed`, not `ok`, since a +real exit code alone is not evidence the build produced firmware -- and (see +[`last_manifest_write`]) the post-build `system-manifest.yaml` write. **tan-cli#307, a DELIBERATE divergence from the frozen Rust oracle.** `crates/` is frozen (`docs/ROADMAP.md`'s standing rule), and the oracle's own @@ -61,6 +65,7 @@ resolve_zephyr_artefact, write_post_build_manifest, write_sdk_stamp, + zephyr_boilerplate_loaded, ) from tan.commands.build.materialise import MaterialiseError, confine_to_build_root from tan.core.plan_exec import ( @@ -73,6 +78,7 @@ ) from tan.core.system_manifest import SliceRunResult from tan.core.venv import west_program, west_workspace_dir, with_venv_on_path +from tan.core.zephyr_env import zephyr_env_overrides from tan.envelope import Issue if os.name != "nt": @@ -506,9 +512,17 @@ def execute_slices( # resolves (CI, an activated venv, the contract harness) -- every west # slice below then keeps its old cwd, matching the pre-fix behaviour # exactly (see [`_pin_west_workspace`]). - workspace_dir = west_workspace_dir( - str(build_root), Path(sdk_root) if sdk_root is not None else None - ) + sdk_root_path = Path(sdk_root) if sdk_root is not None else None + workspace_dir = west_workspace_dir(str(build_root), sdk_root_path) + # tan-cli#308: port of the oracle's `resolve_zephyr_base` -- the + # workspace's own `zephyr/` checkout, filtered to a real directory so a + # `workspace_dir` that resolved but was never `west update`d (no + # `zephyr/` yet) does not hand `west` a `ZEPHYR_BASE` that does not + # exist. `None` propagates through [`zephyr_env_overrides`] as "nothing + # to fill", matching every other `workspace_dir` consumer's fallback. + zephyr_base = workspace_dir / "zephyr" if workspace_dir is not None else None + if zephyr_base is not None and not zephyr_base.is_dir(): + zephyr_base = None for sl in plan.slices: if sl.backend not in KNOWN_BACKENDS: @@ -591,8 +605,33 @@ def execute_slices( ) ) + # tan-cli#308: the zephyr gap-fillers are computed PER SLICE (not + # once for the whole run, unlike `workspace_dir`/`zephyr_base` + # themselves) because "plan wins" depends on THIS slice's own + # `env`/`env_append_path` -- a heterogeneous plan can have one slice + # that already pins `EXTRA_ZEPHYR_MODULES` (an SDK-emitted plan's + # `envAppendPath`) alongside one that doesn't, and the caller's + # `gap_fillers` merge (`assemble_slice_env`) OVERWRITES a key + # unconditionally -- computing this once, outside the loop, would + # silently clobber the plan's own richer module list on every slice + # that DOES pin it. + slice_gap_fillers = [ + *gap_fillers, + *zephyr_env_overrides( + zephyr_base, sdk_root_path, sl.env, sl.env_append_path, env_lookup + ), + ] + # Bound to a name rather than inlined into the `update` call: the + # tan-cli#336 pop below needs to distinguish a `ZEPHYR_BASE` that + # something AUTHORITATIVE put here (the plan's own `env`, or + # tan-cli#308's gap filler) from one merely inherited off + # `os.environ` -- and after the `update` the merged `env` can no + # longer tell those apart. + slice_env = dict( + assemble_slice_env(sl.env, sl.env_append_path, env_lookup, slice_gap_fillers) + ) env = dict(os.environ) - env.update(dict(assemble_slice_env(sl.env, sl.env_append_path, env_lookup, gap_fillers))) + env.update(slice_env) # tan-cli#289/#106: the venv `west` spawns nested `west`/`bitbake` # (via `alp_orchestrate`) that resolve purely via PATH -- without # this they fail to find `west` exactly like the parent process @@ -600,7 +639,7 @@ def execute_slices( # `tool` did not resolve to an absolute venv path above. env = with_venv_on_path(env, tool) - if is_west and workspace_dir is not None and "ZEPHYR_BASE" not in sl.env: + if is_west and workspace_dir is not None and "ZEPHYR_BASE" not in slice_env: # tan-cli#336: a dangling `$ZEPHYR_BASE` inherited from the # ambient shell (seeded above by `dict(os.environ)`) OUTRANKS the # workspace tan just resolved -- west's own `set_zephyr_base` @@ -627,9 +666,21 @@ def execute_slices( # exactly what already happens when `ZEPHYR_BASE` is unset # (verified: an unset `ZEPHYR_BASE` self-heals via the # manifest's "zephyr"-named project, `zephyr.base-prefer` - # unset). A plan that pins `ZEPHYR_BASE` on the slice's OWN - # `env` is left untouched -- "plan wins / CLI fills gaps" - # applies here too (see `assemble_slice_env`'s docstring). + # unset). + # + # Guarded on `slice_env`, NOT on `sl.env`: tan-cli#308's + # `zephyr_env_overrides` fills this key as a gap filler for + # precisely the slices that DON'T pin it themselves, so keying + # off the plan alone would pop #308's freshly-computed value on + # every slice #308 exists to serve and leave only the pop's + # weaker self-heal behind. `slice_env` holds the plan's own env + # AND the gap fillers merged, so "present in `slice_env`" is + # exactly "something authoritative decided this" -- and only an + # ambient, inherited `ZEPHYR_BASE` is dropped. The two fixes + # compose in that order: #308 supplies the right value whenever + # the workspace has a real `zephyr/`; #336 removes a stale + # ambient one for the cases #308 cannot fill (no + # `workspace_dir`, or a workspace not yet `west update`d). env.pop("ZEPHYR_BASE", None) # tan-cli#307: pin `west build` to the workspace tan resolved rather @@ -705,25 +756,20 @@ def _watch_for_no_workspace(line: str) -> None: ) continue - # On success, resolve the real on-disk artefact west produced so the - # post-build manifest points downstream consumers (`run`/`size`/ - # `flash`/`image`) at the elf that exists, not a plan-time guess. - output_artefact, slice_build_dir = ( - resolve_zephyr_artefact(cwd, sl.command.args) if code == 0 else (None, None) - ) + status = "succeeded" if code == 0 else "failed" if code == 0: message = None elif is_west and saw_no_workspace and workspace_dir is not None: # tan-cli#336: west named no cause beyond its own exit code even # though tan was holding a resolved workspace path the whole - # time -- name it, and the `ZEPHYR_BASE` this spawn actually saw - # ("unset" is the honest state after the pop above for every - # slice this fix covers; a real value here means the PLAN - # pinned it, or `workspace_dir` didn't resolve the same - # workspace west itself would have). Plain string interpolation, - # not `!r`: a Windows path's backslashes survive unescaped this - # way, matching every other path already embedded in this - # module's messages (e.g. the sdk-switch-pristine note above). + # time -- name it, and the `ZEPHYR_BASE` this spawn actually saw. + # After tan-cli#308 that value is usually the workspace's own + # `zephyr/`; "unset" means #308 had nothing to fill and the #336 + # pop above ran. Either way it is the fact that distinguishes + # "tan pointed west somewhere wrong" from "west never saw what + # tan resolved". Plain string interpolation, not `!r`: a Windows + # path's backslashes survive unescaped this way, matching every + # other path already embedded in this module's messages. seen_zephyr_base = env.get("ZEPHYR_BASE") or "unset" message = ( f"slice `{sl.core_id}` terminated with exit code: {code} -- west could not " @@ -732,14 +778,56 @@ def _watch_for_no_workspace(line: str) -> None: ) else: message = f"slice `{sl.core_id}` terminated with exit code: {code}" + + # tan-cli#309 (upstream tan-cli #97): a core declared `os: zephyr` + # whose CMakeLists.txt never calls `find_package(Zephyr ...)` still + # configures and links fine under `west build -b ` (CMake + # only emits a *dev* warning about the missing `project()` call), so + # a real exit code 0 is NOT sufficient evidence -- without this the + # out-of-the-box scaffold was reported `[+] ok` for a plain host + # binary with no Zephyr in it at all. Checked only on an otherwise- + # successful slice (a genuine build failure already speaks for + # itself) and skipped when the slice redirects west's own build dir + # (`-d`/`--build-dir`), where the evidence lives somewhere this + # cannot see -- the same refusal `resolve_zephyr_artefact` below + # already makes. + if ( + status == "succeeded" + and sl.backend == "zephyr" + and not build_dir_overridden(sl.command.args) + and not zephyr_boilerplate_loaded(cwd) + ): + status = "failed" + message = ( + f"core `{sl.core_id}` is declared `os: zephyr`, but the build in " + f"`{sl.command.cwd or '.'}` never loaded Zephyr (no ZEPHYR_BASE in its " + f"CMakeCache.txt and no zephyr/ output) — its CMakeLists.txt must call " + f"`find_package(Zephyr REQUIRED HINTS $ENV{{ZEPHYR_BASE}})` before `project()`; " + f"without it CMake builds a plain host binary, not firmware. Scaffold a working " + f"app with `tan init --template zephyr-app`, or point the core's `app:` at one " + f"that does." + ) + + # On success, resolve the real on-disk artefact west produced so the + # post-build manifest points downstream consumers (`run`/`size`/ + # `flash`/`image`) at the elf that exists, not a plan-time guess. + # Gated on the FINAL `status` (after the guard above), not the raw + # exit code: a guard-failed slice has no real Zephyr artefact to + # report even though the tool itself exited 0. + output_artefact, slice_build_dir = ( + resolve_zephyr_artefact(cwd, sl.command.args) if status == "succeeded" else (None, None) + ) outcomes.append( SliceOutcome( sl.core_id, - "succeeded" if code == 0 else "failed", + status, # A negative POSIX return code means the process died from a # signal -- Rust's `ExitStatus::code()` returns `None` for # that case (it has no single-integer exit code), so the - # envelope's `rc` must be null too, not the raw `-N`. + # envelope's `rc` must be null too, not the raw `-N`. Stays + # the tool's REAL exit code even when the guard above + # overrode `status` to "failed" -- `west build` really did + # exit 0, the guard is refusing the RESULT, not the exit. None if code < 0 else code, message, output_artefact, diff --git a/python/tan/commands/build/manifest.py b/python/tan/commands/build/manifest.py index d6b2aae0..bd526995 100644 --- a/python/tan/commands/build/manifest.py +++ b/python/tan/commands/build/manifest.py @@ -3,14 +3,14 @@ manifest.yaml` the downstream `tan run`/`tan flash`/`tan size`/`tan image` contract reads, and resolve the real on-disk `zephyr.elf` a slice produced. -Port of `crates/tan-cli/src/commands/build/execute/manifest.rs`, trimmed to -this port's current scope the same way `tan.commands.build.execute` already -is (see that module's docstring): no Zephyr-boilerplate-loaded guard. What IS +Port of `crates/tan-cli/src/commands/build/execute/manifest.rs`. What's ported: the post-build `write_post_build_manifest` seam and its two in-memory signals (`write_failed_reason` / `native_sim_target`), `resolve_zephyr_artefact`'s -default-nested-build-dir artefact resolution, and the SDK-identity stamp +default-nested-build-dir artefact resolution, the SDK-identity stamp (`sdk_stamp_path`/`read_sdk_stamp`/`write_sdk_stamp`/`cmake_cache_configured`) -the sdk-switch-pristine guard in `execute.py` reads and writes (issue #52). +the sdk-switch-pristine guard in `execute.py` reads and writes (issue #52), +and (tan-cli#309) the Zephyr-boilerplate-loaded guard (tan-cli #97 upstream) +[`zephyr_boilerplate_loaded`] -- `execute.py`'s status assembly applies it. **Why `sdk_root`/`board_yaml` stay ACCEPTED overrides here rather than always required.** The Rust oracle's `write_post_build_manifest` takes a @@ -51,6 +51,7 @@ "sdk_stamp_path", "write_post_build_manifest", "write_sdk_stamp", + "zephyr_boilerplate_loaded", ] @@ -318,3 +319,59 @@ def cmake_cache_configured(slice_cwd: Path) -> bool: `sdk_stamp_action` needs before it treats a missing stamp as stale. Port of `manifest.rs::cmake_cache_configured`.""" return (slice_cwd / "build" / "CMakeCache.txt").is_file() + + +def _dir_shows_zephyr(directory: Path) -> bool: + """The two per-directory signals behind [`zephyr_boilerplate_loaded`]: + a `ZEPHYR_BASE:` entry in `directory`'s own `CMakeCache.txt` (the primary + signal -- what `find_package(Zephyr)` caches, verified against a real + `/build/CMakeCache.txt`; a plain host configure never writes one), + OR a `zephyr/` subdirectory (Zephyr's boilerplate binary dir, kept as an + OR fallback so the guard can only ever fail a build it is SURE about). + Port of `manifest.rs::dir_shows_zephyr`, whose `std::fs::read_to_string` + folds invalid-UTF-8 into the SAME `io::Error` a missing file raises + (`.is_ok_and(...)` then just falls through to the `zephyr/` fallback); + `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so `except + OSError` alone let a non-UTF-8 `CMakeCache.txt` escape this function as + an uncaught exception -- the same lesson `read_sdk_stamp` above already + records for the sibling `.tan-sdk-root` read.""" + try: + cache = (directory / "CMakeCache.txt").read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + cache = "" + if any(line.startswith("ZEPHYR_BASE:") for line in cache.splitlines()): + return True + return (directory / "zephyr").is_dir() + + +def zephyr_boilerplate_loaded(slice_cwd: Path) -> bool: + """tan-cli#309 (upstream tan-cli #97): whether this slice's build dir + shows that Zephyr's CMake boilerplate actually ran -- the signal behind + the `os: zephyr` guard `execute.py`'s status assembly applies. + + The reported defect: a project whose `CMakeLists.txt` never calls + `find_package(Zephyr ...)` still configures and links fine under `west + build -b ` (CMake only emits a *dev* warning about the missing + `project()` call), so a core declared `os: zephyr` produced a host + binary and the executor reported it `[+] ok`. The board name is never + even validated, because nothing loaded the code that would validate it. + + Checked one level down too (not just `/build` itself), + because `--sysbuild` nests the real per-image Zephyr builds one + directory deeper under its own superbuild -- Zephyr's own + `share/sysbuild/CMakeLists.txt` calls `find_package(Sysbuild ...)`, not + `find_package(Zephyr)`, so a sysbuild top level carries neither signal + and only the nested per-image build does. One level is enough (sysbuild + nests per-image, not recursively). + + Callers must skip this check when [`build_dir_overridden`] -- west then + wrote somewhere this can't see, the same refusal [`resolve_zephyr_artefact`] + already makes. Port of `manifest.rs::zephyr_boilerplate_loaded`.""" + build = slice_cwd / "build" + if _dir_shows_zephyr(build): + return True + try: + children = [p for p in build.iterdir() if p.is_dir()] + except OSError: + return False + return any(_dir_shows_zephyr(child) for child in children) diff --git a/python/tan/commands/build_cmd.py b/python/tan/commands/build_cmd.py index 98f1f573..828adf7c 100644 --- a/python/tan/commands/build_cmd.py +++ b/python/tan/commands/build_cmd.py @@ -805,12 +805,14 @@ def _dispatch( replace(plan, slices=runnable), build_root=build_root, env_lookup=os.environ.get, - # NOT YET PORTED: Rust fills ZEPHYR_BASE and EXTRA_ZEPHYR_MODULES - # here from the resolved west workspace, so `west build -b - # ` finds the SDK's boards without the user wiring - # -DEXTRA_ZEPHYR_MODULES. Plans emitted by the SDK carry both on - # the slice's own envAppendPath, so this is a gap only for a host - # relying on the CLI to fill them. + # tan-cli#308: no build_cmd.py-level gap fillers of our own -- + # `execute_slices` fills ZEPHYR_BASE/EXTRA_ZEPHYR_MODULES + # itself, PER SLICE, from the west workspace it resolves + # internally (`tan.core.zephyr_env.zephyr_env_overrides`), + # exactly where the Rust oracle's own `execute_slices` + # computes them (`execute/mod.rs`, inside its own per-slice + # loop) -- not from an outer caller. This parameter stays for + # a caller-supplied override this port has none of yet. gap_fillers=(), on_output=heartbeat, sdk_root=sdk_root, @@ -1312,6 +1314,24 @@ def build( # walk, so `tan init`'s own pointer went unread the moment `tan build` ran # in the same directory. resolved_sdk_root, sdk_tier, sdk_broken_pin = resolve_sdk_root_ladder(sdk_root, workspace_root) + # tan-cli#257/#258: `resolve_sdk_root_ladder` returns an explicit + # `--sdk-root` UNVALIDATED (I-31 terminal-for-REPORTING, matching the + # oracle's `resolve_sdk_tiered`) -- fine for a caller that only reports + # the tier, but `build` also ACTS on `resolved_sdk_root`, so a bogus flag + # used to sail through as `sdk.sourceTier: "sdkRootFlag"`, reach + # `_emit_plan` as a non-None `sdk_root`, and get refused for the NEXT + # missing thing (`no board.yaml found`) instead -- telling the customer + # their project is broken when the `--sdk-root` they just typed is what's + # wrong, and reporting an `sdk` key the oracle never emits on this path. + # Validated here, at the flag's own entry point, rather than in the + # shared ladder (which every other caller also relies on staying + # unvalidated) -- same shape as `clean_cmd.sdk_root_resolves` and + # `flash_cmd._resolve_sdk`, the two callers that already guard their own + # explicit `--sdk-root`. An unresolvable explicit root is treated as no + # root at all: `_emit_plan` then gives its own "no alp-sdk checkout + # found" refusal, and no `sdk` key is reported, matching the oracle. + if sdk_tier == "sdkRootFlag" and not _is_sdk_root(resolved_sdk_root): + resolved_sdk_root = None sdk_root = str(resolved_sdk_root) if resolved_sdk_root is not None else None sdk = SdkInfo(sdk_root, sdk_tier) if sdk_root is not None else None # Absolute, `.`/`..`-collapsed, anchored on `workspace_root` -- what the diff --git a/python/tan/commands/clean_cmd.py b/python/tan/commands/clean_cmd.py index 0117092b..878d3ff4 100644 --- a/python/tan/commands/clean_cmd.py +++ b/python/tan/commands/clean_cmd.py @@ -1,1005 +1,1005 @@ -# SPDX-License-Identifier: Apache-2.0 -"""`tan clean` -- remove this project's build root, the orchestrator's state -cache, and any out-of-tree slice build dir the system manifest names. - -Port of `crates/tan-cli/src/commands/clean.rs` plus the pure planning half it -delegates to (`tan_core::clean` + `tan_core::path_guard::is_unsafe_removal_target`). -Ported into ONE file deliberately: the pure part is ~50 lines with exactly one -consumer, and a separate `tan/core/clean.py` holding a four-line guard would be -an abstraction with a single call site. Every function names the Rust item it -mirrors. - -**This command DELETES, so the safety rules are stricter than anywhere else in -the port:** - -* Every removal candidate is screened by [`is_unsafe_removal_target`] -- the - build root included -- BEFORE any filesystem call. A candidate that IS the - project root, an ancestor of it, or a bare filesystem/drive/UNC root is - REFUSED and reported, never silently dropped and never removed. That covers - the `rm -rf $UNSET_VAR` shape: `--build-root ""`, `.` and `..` all resolve to - the project root or above, as does a manifest `build_dir: ""`. -* The screen is NOT "must stay under the build root", and must not become that. - `confine_to_build_root` -- the hardened containment guard this module DOES - reuse, see [`_subsumed_by_build_root`] -- answers a different question, and - two of the three target classes the oracle removes are legitimately OUTSIDE - the build root: the app-root `.alp-build-state.json`, and an out-of-tree slice - `build_dir` such as a Yocto tmp dir. Verified against the Rust binary: - `tan clean --build-root ../outside` removes `../outside` and exits 0. - Applying containment to every target would refuse two supported cases and - diverge from the oracle on a destructive command. The rule is "not - catastrophic", not "not outside" (`path_guard.rs:100-103`). -* A symlink or junction is never followed OUT of the tree. `shutil.rmtree` - refuses a link outright, and [`_remove_dir`] removes the LINK itself instead - -- so a `build/` junctioned at another directory unlinks the junction and - leaves its target intact (verified against the Rust binary, whose - `remove_dir_all` does the same on Windows). -* `--dry-run` reaches no removal call at all: [`_classify`] returns a - `would-remove` disposition and the removal arms are never entered. -* The build root is never guessed. An unresolvable SDK is exit 1 - (`clean.sdk-root-not-found`) and an unsafe build root is exit 1 - (`clean.unsafe-build-root`), rather than a best-effort removal of something - nearby. - -**Nothing here learns a hardware fact and nothing shells the SDK.** The -checkout is probed for its loader marker (`scripts/alp_project.py`, I-31) and -otherwise untouched: removing a build directory needs no SDK, and invoking one -would give `clean` a dependency it deliberately does not have (I-32, port-spec -anti-pattern #22). The only project input beyond the arguments is -`/system-manifest.yaml`, which this project's own build wrote. - -Every failure path emits a coded envelope. An escaping traceback puts nothing -parseable on stdout and the extension then renders an empty panel with no -error, so [`clean`]'s outer guard converts any unexpected exception into -`clean.internal-failure` at exit 5. Its recovery path builds the envelope from -constants only -- never a helper that can itself throw -- because a helper -called from the recovery path is how a single fault became a DOUBLE fault -elsewhere in this port. - -**KNOWN GAP, for whoever owns packaging.** The manifest sweep needs a YAML -parser and tan declares none; `scripts/build_binary.sh` documents the frozen -binary's build environment as `pip install typer rich pyinstaller`, so the -SHIPPED `tan clean` takes the no-PyYAML arm and emits a -`clean.manifest-unreadable` warning on every project that has ever been built --- where the Rust oracle emits nothing. The behaviour is correct (see -[`parse_manifest_slices`]: reported, never swallowed, never fatal) but noisy. -Closing it is a packaging call -- add PyYAML to the frozen build, weighed against -the artefact-size budget `build_binary.sh` records -- and deliberately NOT a -hand-rolled fallback scanner here: a mis-parse would name a PATH handed to a -recursive removal. -""" - -from __future__ import annotations - -import os -import shutil -import stat -import sys -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import typer - -from tan.commands.build.materialise import MaterialiseError, confine_to_build_root -from tan.commands.build_cmd import resolve_sdk_root_ladder -from tan.commands.presets_cmd import resolve_project_paths, resolve_sdk -from tan.commands.sdk_cmd import SDK_MARKER, project_pin_issue -from tan.envelope import Envelope, Issue, Project, SdkInfo, emit -from tan.exit_codes import ExitCode - -#: The orchestrator state cache removed alongside the build root -- verbatim -#: `alp_clean.py`'s `targets[1]`. The orchestrator actually writes its cache at -#: `/.alp-build-state.json`, already subsumed by the recursive -#: build-root removal; this app-root path is the faithful, usually-absent target -#: the Python cleaner kept. Preserved, not "fixed" (`tan_core::clean` docs). -STATE_FILE = ".alp-build-state.json" - -#: The manifest this command sweeps for out-of-tree slice build dirs, relative -#: to the resolved build root. -MANIFEST_NAME = "system-manifest.yaml" - -#: The system-manifest schema major consumed here. A different value is a -#: warning and the manifest is IGNORED -- never read as if it were v1 -#: (`SYSTEM_MANIFEST_SCHEMA_VERSION`). -MANIFEST_SCHEMA_VERSION = 1 - - -# --------------------------------------------------------------------------- -# Pure path shape -- `tan_core::path_guard` -# --------------------------------------------------------------------------- - - -def _rust_join(base: str, rel: str) -> str: - """`PathBuf::push` semantics, which `os.path.join` does not have on Windows. - - Rust replaces the base OUTRIGHT when the right-hand side carries its own - prefix -- a drive (`C:foo`), a UNC share (`\\\\server\\share\\x`) or the - device namespace (`\\\\?\\C:\\x`). `ntpath.join` agrees for a DIFFERENT - drive but treats a drive-relative path on the SAME drive as relative to the - accumulated path, so `join("C:/proj", "C:foo")` yields `C:/proj\\foo` where - Rust yields `C:foo`. `C:foo` is one of the shapes a `--build-root` guard has - to get right, so the divergence is closed here rather than tolerated. - - Everything else defers to `os.path.join`, which already matches Rust for the - rooted-but-prefixless case (`\\rooted` keeps the base's drive) and for the - empty and `..` cases (verified against the Rust binary). On POSIX - `splitdrive` always reports no drive, so this is `os.path.join` verbatim. - """ - if os.path.splitdrive(rel)[0]: - return rel - return os.path.join(base, rel) - - -def _normalize(path: str) -> str: - """Lexically collapse `.`/`..` without touching the filesystem -- - `path_guard::normalize`. - - `os.path.normpath`, so no symlink is resolved and a path that does not exist - still normalizes. Used for COMPARISON only; a reported path keeps whatever - spelling the oracle would report. - - One known divergence, unreachable here: Rust's `normalize` pops past the - start, so a relative `..` collapses to the empty path where `normpath` - keeps `..`. Every input below is already absolute (the project root is - cwd-anchored, and the build root and slice dirs are joined onto it), so the - difference cannot be reached. - """ - return os.path.normpath(path) - - -def _has_normal_component(normalized: str) -> bool: - """Whether the path names anything at all beyond a root/prefix -- Rust's - `components().any(Component::Normal)`. - - False for `/`, `C:\\`, `C:` and a bare UNC share `\\\\server\\share`: each is - a root whose recursive removal takes out far more than a build tree. - """ - return os.path.splitdrive(normalized)[1].strip("\\/") != "" - - -def _is_under(base: str, path: str) -> bool: - """Component-wise containment on normalized paths -- Rust's - `Path::starts_with`, NOT a string prefix test. - - `/p/build` contains `/p/build/x` and itself, but NOT the sibling - `/p/build2` -- which a plain `str.startswith` would wrongly accept, and - which decides whether a slice dir is treated as a separate removal target. - Case-sensitive, matching Rust (which folds case on the Windows drive prefix - only, and both sides here come from the same join). - """ - base_n, path_n = _normalize(base), _normalize(path) - if base_n == path_n: - return True - return path_n.startswith(base_n.rstrip("\\/") + os.sep) - - -def is_unsafe_removal_target(project_root: str, target: str) -> bool: - """True when recursively removing `target` would take out far more than a - build tree, and the caller must refuse -- `path_guard::is_unsafe_removal_target`. - - Rejects a filesystem/drive/UNC root (no `Normal` component at all), and the - project root itself or any ancestor of it. Deliberately does NOT require - containment under the project root: an out-of-tree slice build dir is a - supported clean target (see the module docstring). - """ - target_n = _normalize(target) - if not _has_normal_component(target_n): - return True - # True when the target IS the project root or an ancestor of it -- both - # would delete the user's sources. - return _is_under(target_n, project_root) - - -# --------------------------------------------------------------------------- -# Pure removal planning -- `tan_core::clean` -# --------------------------------------------------------------------------- - -#: Filesystem-kind + disposition pairs, keyed by what a probe found. -_DIR_ACTIONS = {False: ("dir", "removed"), True: ("dir", "would-remove")} -_FILE_ACTIONS = {False: ("file", "removed"), True: ("file", "would-remove")} - - -@dataclass(frozen=True) -class _Rejected: - """A candidate refused as too dangerous to remove, with where it came from - so the message can name the culprit -- `clean::RejectedTarget`.""" - - path: str - #: `build-root` | `slice` -- the state file is never screened (see - #: [`plan_clean_targets`]), so it has no rejection message. - origin: str - core_id: str = "" - raw: str = "" - - def reason(self) -> str: - """One-line explanation naming the source, verbatim from - `RejectedTarget::reason`. The em dash is the oracle's own character.""" - if self.origin == "slice": - return ( - f"refusing to remove slice '{self.core_id}' build_dir " - f'"{self.raw}" (resolves to {self.path}) \u2014 it is the ' - "project root, an ancestor of it, or a filesystem root; fix " - "build/system-manifest.yaml" - ) - return ( - f"refusing to remove build root {self.path} \u2014 it is the " - "project root, an ancestor of it, or a filesystem root" - ) - - -@dataclass -class _Plan: - """`clean::CleanPlan`: paths cleared for removal (build root first), plus - everything refused. A non-empty `rejected` means the command must report and - fail -- never quietly clean less than asked.""" - - targets: list[str] = field(default_factory=list) - rejected: list[_Rejected] = field(default_factory=list) - - -def _subsumed_by_build_root(build_root: str, resolved: str) -> bool: - """Whether a slice `build_dir` is already covered by the recursive build-root - removal, so it contributes no extra target. - - Two tests, and EITHER answering "inside" is enough: - - * the oracle's lexical `clean::is_under`, which is what parity is measured - against; and - * [`confine_to_build_root`], the port's hardened containment guard, reused - here rather than re-derived -- this is the one question in `clean` whose - semantics really are "is this path confined under the build root". It - resolves both sides, so it also catches a junction or symlink inside - `build/` that points out of the tree, and the Windows shapes - (`C:foo`, `\\x`, UNC, `\\\\?\\`) a lexical test misses. - - OR, not AND, deliberately: a disagreement can then only make the port treat - a path as ALREADY COVERED, i.e. remove strictly less than the oracle -- and - the disagreement only arises when the path genuinely does live inside - `build_root`, which the recursive removal handles anyway, so the resulting - disk state is identical. Requiring both to agree would let a resolved-inside - path become a SEPARATE `shutil.rmtree` call, which is the one direction a - destructive command must not drift in. - """ - if _is_under(build_root, resolved): - return True - if not os.path.isabs(resolved): - # A DRIVE-RELATIVE leftover (`C:rel`, the one shape `_rust_join` cannot - # make absolute because Rust does not either). It cannot be containment- - # tested against an unrelated base without inventing a meaning for it: - # `Path("C:/proj/build") / "C:rel"` re-reads it as relative to the base - # and answers "inside", while Rust reports it as its own target resolved - # against drive C:'s current directory. Measured -- a manifest - # `build_dir: "C:rel"` made the oracle list an `absent` target the port - # silently dropped. The lexical answer above IS the oracle's answer here, - # and the candidate is still screened by `is_unsafe_removal_target`. - return False - try: - confine_to_build_root(Path(build_root), resolved) - except (MaterialiseError, OSError, ValueError): - # `MaterialiseError` is the escape verdict; `OSError`/`ValueError` come - # from `Path.resolve()` on a shape the host rejects outright (a device- - # namespace path, an over-long name). Either way: not proven inside. - return False - return True - - -def plan_clean_targets( - project_root: str, build_root: str, slices: list[dict[str, Any]] -) -> _Plan: - """Ordered, de-duplicated removal targets -- `clean::clean_targets`. - - 1. `build_root`, recursively. - 2. `/.alp-build-state.json`. - 3. each slice `build_dir` that lies OUTSIDE `build_root` (see - [`_subsumed_by_build_root`]); a relative value resolves against - `project_root`, an absolute one is taken as-is. - - Every candidate except the state file is then screened by - [`is_unsafe_removal_target`]. The manifest is unvalidated file content and - `build_dir: ""`, `.`, `/` or `../..` each resolve to the project root or - above; a rejected candidate goes to `rejected` so the caller can surface it - and fail, never silently dropped. The state file is exempt because it is a - single unlink of one fixed name under the project root, never a recursive - removal -- matching the oracle's own exemption. - """ - candidates: list[tuple[str, _Rejected | None]] = [ - (build_root, _Rejected(build_root, "build-root")), - (_rust_join(project_root, STATE_FILE), None), - ] - for entry in slices: - raw = entry.get("build_dir") - if not isinstance(raw, str): - continue - resolved = _rust_join(project_root, raw) - if not _subsumed_by_build_root(build_root, resolved): - core_id = entry.get("core_id", "") - candidates.append( - # `str()`: a plain-scalar `core_id: 7` is a valid String to - # serde_yaml, so it can reach the rejection message as an int. - (resolved, _Rejected(resolved, "slice", str(core_id), raw)) - ) - - plan = _Plan() - seen: list[str] = [] - for path, rejection in candidates: - key = _normalize(path) - if key in seen: - continue - seen.append(key) - if rejection is None or not is_unsafe_removal_target(project_root, path): - plan.targets.append(path) - else: - plan.rejected.append(rejection) - return plan - - -def _classify(is_dir: bool, is_file: bool, dry_run: bool) -> tuple[str, str]: - """`(kind, action)` from a probed filesystem type + the dry-run flag -- - `clean::classify`. A path that is neither a dir nor a file is `absent`: - skipped entirely, not counted, no removal attempted.""" - if is_dir: - return _DIR_ACTIONS[dry_run] - if is_file: - return _FILE_ACTIONS[dry_run] - return ("absent", "absent") - - -# --------------------------------------------------------------------------- -# The system-manifest sweep -# --------------------------------------------------------------------------- - - -#: `serde` renders an unexpected value as `` for containers and -#: <kind> `value` for scalars. Harvested from the Rust binary -#: across 25 malformed-manifest shapes, so the port's warning text matches -#: rather than approximates. -_SERDE_KIND = { - type(None): "unit value", - bool: "boolean", - int: "integer", - float: "floating point", - str: "string", - list: "sequence", - dict: "map", -} - - -def _serde_value(value: Any) -> str: - """How serde names an unexpected value in `invalid type: ...`. - - `sequence`/`map`/`unit value` carry no payload; a bool renders lowercase - (`true`), a string in double quotes, a number in backticks. - """ - kind = _SERDE_KIND.get(type(value), type(value).__name__) - if value is None or isinstance(value, (list, dict)): - return kind - if isinstance(value, bool): - return f"{kind} `{'true' if value else 'false'}`" - if isinstance(value, str): - return f'{kind} "{value}"' - return f"{kind} `{value}`" - - -def _is_yaml_scalar(value: Any) -> bool: - """Whether serde_yaml would accept this value for a `String` field. - - YAML plain scalars are untyped, and serde_yaml 0.9 hands one to whichever - visitor the target field asks for -- so `core_id: 7` deserializes into - `String` as `"7"` with no error. Verified against the Rust binary - (`core_id: 7` parses clean and the run exits 0 with no issue). A port that - demanded `isinstance(str)` here would emit a `clean.manifest-unreadable` - warning the oracle does not. - """ - return isinstance(value, (str, int, float, bool)) - - -def parse_manifest_slices(text: str) -> tuple[list[dict[str, Any]], str | None]: - """`(slices, error)` for a `system-manifest.yaml` document -- - `parse_system_manifest`, narrowed to the one field this command consumes. - - FAIL-CLOSED, matching serde: a document that is not a well-formed v1 - manifest yields NO slices and an error string, so a half-read manifest can - never hand a garbage `build_dir` to a recursive removal. Tolerant of - additive v1 fields (`deny_unknown_fields` is deliberately off upstream). - - Message text was matched against the Rust binary shape by shape; two - divergences remain and are deliberate: - - * the trailing ` at line N column M` serde_yaml appends is absent -- - `yaml.safe_load` discards node marks, and recovering them would mean a - custom composer for a warning string; - * a raw YAML SYNTAX error (a stray tab, an unclosed flow node) carries - PyYAML's wording after the shared `system-manifest is not valid YAML: ` - prefix. - - `build_dir` REQUIRES a real string, where serde_yaml would coerce a plain - scalar (`build_dir: 7` becomes `"7"` and, verified against the Rust binary, - a THIRD removal target at `/7`). Diverging here is deliberate: this - is the only manifest field that becomes a path handed to a recursive - removal, and deriving a delete target from a number in a malformed manifest - is not a behaviour worth reproducing. The port removes strictly less, the - SDK emits strings, and [`test_numeric_build_dir_is_not_turned_into_a_delete_target`] - pins it so the choice cannot drift silently. - - PyYAML is optional -- tan declares no YAML dependency -- and its absence is - REPORTED rather than swallowed: a project whose slices build out of tree - would otherwise have them left behind with no indication why. Still only a - warning; `clean` never fails over a manifest. - """ - try: - import yaml # noqa: PLC0415 (optional at runtime, by design) - except ImportError: # pragma: no cover -- present in every real workspace - return [], ( - "no YAML parser available (PyYAML is not installed), so out-of-tree " - "slice build dirs were not swept" - ) - try: - doc = yaml.safe_load(text) - except Exception as err: # noqa: BLE001 -- any parser failure, incl. the C ext - return [], f"system-manifest is not valid YAML: {err}" - - prefix = "system-manifest is not valid YAML: " - if doc is None: - # `yaml.safe_load` collapses two cases serde keeps apart: a file with no - # document at all (empty, whitespace-only, comments-only, or a bare - # `---`) has no fields, so serde reports the first REQUIRED one; an - # explicit `null`/`~` node is a real value of the wrong TYPE. Both were - # measured against the Rust binary. Told apart by whether the source - # carries any node text of its own. - body = "\n".join( - line - for line in text.splitlines() - if line.strip() not in ("", "---", "...") and not line.lstrip().startswith("#") - ) - if not body.strip(): - return [], f"{prefix}missing field `schema_version`" - return [], f"{prefix}invalid type: unit value, expected struct SystemManifest" - if not isinstance(doc, dict): - return [], f"{prefix}invalid type: {_serde_value(doc)}, expected struct SystemManifest" - - if "schema_version" not in doc: - return [], f"{prefix}missing field `schema_version`" - version = doc["schema_version"] - # `bool` is an `int` in Python but never a serde `u32`, so it is a type - # error, not a version. Checked before the int test for that reason. - if isinstance(version, bool) or not isinstance(version, int): - return [], ( - f"{prefix}schema_version: invalid type: {_serde_value(version)}, expected u32" - ) - if version != MANIFEST_SCHEMA_VERSION: - return [], ( - f"unsupported system-manifest schema_version {version} (this CLI " - f"consumes v{MANIFEST_SCHEMA_VERSION}); upgrade the CLI or the SDK " - "so the versions match" - ) - - raw = doc.get("slices", []) - if not isinstance(raw, list): - return [], f"{prefix}slices: invalid type: {_serde_value(raw)}, expected a sequence" - for index, entry in enumerate(raw): - # `core_id`/`os` are non-Option in the Rust `Slice`, so serde fails the - # WHOLE document when either is missing; `build_dir` is - # `Option`, so `null`/absent is fine and a sequence is not. A - # partial read here would act on a manifest the oracle rejects outright. - if not isinstance(entry, dict): - return [], ( - f"{prefix}slices[{index}]: invalid type: {_serde_value(entry)}, " - "expected struct Slice" - ) - for required in ("core_id", "os"): - if not _is_yaml_scalar(entry.get(required)): - missing = required not in entry or entry.get(required) is None - return [], ( - f"{prefix}slices[{index}]: missing field `{required}`" - if missing - else f"{prefix}slices[{index}].{required}: invalid type: " - f"{_serde_value(entry[required])}, expected a string" - ) - build_dir = entry.get("build_dir") - if build_dir is not None and not _is_yaml_scalar(build_dir): - return [], ( - f"{prefix}slices[{index}].build_dir: invalid type: " - f"{_serde_value(build_dir)}, expected a string" - ) - return list(raw), None - - -def _read_manifest(build_root: str) -> tuple[list[dict[str, Any]], str | None]: - """The manifest's slices, or `([], error)`. - - A READ failure -- absent, a directory in its place, a denied ACL, non-UTF-8 - bytes -- is SILENT (`([], None)`), matching the oracle's `Err(_) => None` - arm: no issue, no text, exit unchanged. Verified against the Rust binary for - both the directory and the non-UTF-8 cases. Only a document that WAS read - and could not be understood is a warning. - """ - try: - text = Path(build_root, MANIFEST_NAME).read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError, ValueError): - return [], None - return parse_manifest_slices(text) - - -# --------------------------------------------------------------------------- -# Removal -# --------------------------------------------------------------------------- - - -def is_link(path: str) -> bool: - """Whether `path` is a link that must not be followed -- a POSIX symlink, a - Windows directory symlink, OR a Windows JUNCTION. - - **`os.path.islink` is not this test.** On Windows `ntpath.islink` returns - True only for `IO_REPARSE_TAG_SYMLINK`; a junction is - `IO_REPARSE_TAG_MOUNT_POINT`, and `stat.S_ISLNK` is False for it as well. - Measured on this host: for `build/` junctioned at an out-of-tree directory, - `os.path.islink` and `S_ISLNK` both report False while - `st_reparse_tag == IO_REPARSE_TAG_MOUNT_POINT`. A guard written on - `os.path.islink` therefore lets a junction reach `shutil.rmtree` -- which - has its OWN, correct check (`shutil._rmtree_islink`, mirrored here) and - refuses, so nothing outside the tree is destroyed, but the junction is then - never cleaned and the run reports a spurious `remove-failed`. This was a - live defect in the first cut of this port, caught only by diffing against - the Rust binary. - """ - try: - st = os.lstat(path) - except (OSError, ValueError): - return False - if stat.S_ISLNK(st.st_mode): - return True - attributes = getattr(st, "st_file_attributes", 0) - return bool( - attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) - and getattr(st, "st_reparse_tag", 0) - == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", -1) - ) - - -def os_error_text(err: BaseException) -> str: - """An `OSError` rendered the way Rust's `io::Error` Display renders it: - ` (os error )`. - - Python's own `str(OSError)` is `[WinError 32] : ''`, which - both differs from the oracle and repeats a path the message already names. - The Windows error code (`winerror`) is preferred over the translated - `errno`, matching Rust, which reports the raw OS code. - - One character still differs on Windows: `FormatMessageW` ends its sentences - with a period and Rust keeps it, while Python's `strerror` strips it. Not - synthesized here -- guessing at punctuation inside a system message is worse - than a documented one-character divergence in a warning string. - """ - if not isinstance(err, OSError): - return str(err) - code = getattr(err, "winerror", None) or err.errno - if err.strerror is None or code is None: - return str(err) - return f"{err.strerror} (os error {code})" - - -def _retry_after_clearing_readonly(func, path, _exc=None) -> None: - """`shutil.rmtree` error hook: clear the read-only bit and retry once. - - Rust's `remove_dir_all` deletes a read-only file on Windows outright (it - passes `FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE`), where `shutil.rmtree` - fails the WHOLE tree with `[WinError 5] Access is denied`. Measured against - the Rust binary: one read-only file inside `build/` had Rust remove the - build dir and exit 0 while the port left every artefact in place and warned. - Read-only build outputs are ordinary -- some toolchains mark generated files - that way -- so this is the primary path, not an exotic one. - - `st_mode | S_IWUSR` rather than a bare `S_IWRITE`: on POSIX the latter would - replace the whole mode with `0o200` and strip the owner's read/execute bits - from a directory mid-walk. A failure here propagates out of `rmtree` and is - reported by the caller as `clean.remove-failed`. - """ - os.chmod(path, os.stat(path).st_mode | stat.S_IWUSR) - func(path) - - -#: `shutil.rmtree`'s error-hook keyword. `onerror` is deprecated from 3.12 and -#: scheduled for removal; `onexc` does not exist before it. Selected once here so -#: the call site stays a single expression on either interpreter -- the handler -#: signature is compatible because it ignores its third argument, which is the -#: only thing the two hooks disagree about (`exc_info` tuple vs exception). -_RMTREE_HOOK = "onexc" if sys.version_info >= (3, 12) else "onerror" - - -def _remove_dir(path: str) -> None: - """Remove a directory target recursively, never following a link out of the - tree. - - A link ([`is_link`]) is unlinked ITSELF, exactly as the oracle's - `remove_dir_all` does on Windows: verified against the Rust binary with - `build/` junctioned at an out-of-tree directory -- the junction goes, the - target's contents stay. `shutil.rmtree` handles the ordinary case and never - recurses through a link INSIDE the tree, so both arms are contained. - - `os.rmdir` before `os.unlink`: on Windows a junction or directory symlink is - removed by `RemoveDirectory`, and `unlink` fails on it; on POSIX `rmdir` - fails on a symlink and `unlink` is what removes it. - """ - if is_link(path): - try: - os.rmdir(path) - except OSError: - os.unlink(path) - return - shutil.rmtree(path, **{_RMTREE_HOOK: _retry_after_clearing_readonly}) - - -# --------------------------------------------------------------------------- -# SDK resolution -# --------------------------------------------------------------------------- - - -def _cli_workspace_root(project_arg: str | None) -> Path: - """`util::cli_workspace_root`: `--project` joined to the cwd, UNNORMALIZED - (the oracle normalizes only for the reported `project.root`), or the cwd - itself when the flag is absent. Feeds the SDK guard's discovery walk, whose - sibling/ancestor probes are lexical -- so the unnormalized spelling is what - keeps the two implementations probing the same directories.""" - try: - cwd = os.getcwd() - except OSError: - cwd = "." # `current_dir().unwrap_or_else(|_| PathBuf::from("."))` - return Path(cwd if project_arg is None else _rust_join(cwd, project_arg)) - - -def sdk_root_resolves(sdk_root: str | None, workspace_root: Path) -> bool: - """Whether `build_cmd.resolve_sdk_root_ladder` would resolve a checkout -- - the guard behind `clean.sdk-root-not-found`. - - `--sdk-root` is TERMINAL (I-31): an explicit path without the loader marker - fails here rather than falling through to a lower tier and cleaning against - a checkout the caller never named -- checked explicitly below because the - ladder itself returns a `--sdk-root` value unvalidated (matching the - oracle's `resolve_sdk_tiered`, terminal for REPORTING); this gate matches - `util::resolve_sdk_root`, terminal AND validated. The project-pin and - global-default tiers are best-effort, so a stale pointer falls through - instead of locking the user out. - - The ladder's LAST tier is the wide positional walk (root, child `alp-sdk`, - sibling `alp-sdk`, sibling `alp-sdk-upstream`, then ancestors; first match - wins), but it is reached only when the narrower `resolve_sdk_tiered` - discovery tier AHEAD of it answers `None` -- a narrow hit short-circuits. - So in a workspace holding BOTH a child `alp-sdk` and a lateral one, what - gates this command is the lateral checkout, not the child, and the wide - walk never runs (measured against the oracle: `tan clean` there resolves - `../alp-sdk` too, tan-cli#263). - - That ordering does not move this boolean: every candidate the narrow tier - probes is also one the wide walk probes, so a narrow hit implies a wide - hit. What the wide tail still buys is the case the narrow tier cannot - answer -- a `tan bootstrap` workspace whose checkout is a CHILD of the cwd, - where narrow returns `None` and, without the tail, `tan clean` would refuse - to run (tan-cli#218; measured: the oracle resolves `/alp-sdk` there). - - Note this gate and the REPORTED `sdk` key are still two different - resolutions: [`resolve_sdk`][tan.commands.presets_cmd.resolve_sdk] (below) - reports through `resolve_sdk_tiered` alone, so a bootstrap-child workspace - gates open here while reporting no `sdk` at all. - """ - resolved, tier, _broken_pin = resolve_sdk_root_ladder(sdk_root, workspace_root) - if resolved is None: - return False - if tier == "sdkRootFlag": - return resolved.joinpath(*SDK_MARKER).exists() - return True - - -# --------------------------------------------------------------------------- -# Envelope assembly -# --------------------------------------------------------------------------- - - -@dataclass -class _Outcome: - """What one run produced. Built and returned, never emitted in place, so the - exception guard in [`clean`] can wrap the whole computation without also - catching `typer.Exit` (a `RuntimeError` subclass, not a `SystemExit`, which a - bare `except Exception` would otherwise swallow).""" - - exit_code: ExitCode - data: dict[str, Any] - project: Project - sdk: SdkInfo | None - issues: list[Issue] - text: list[str] - - -def _report(build_root: str, dry_run: bool, targets: list[dict[str, str]], removed: int): - return { - "buildRoot": build_root, - "dryRun": dry_run, - "targets": targets, - "removed": removed, - } - - -def _run( - *, - app_path: str, - build_root_arg: str | None, - dry_run: bool, - project_arg: str | None, - board_yaml_arg: str | None, - sdk_root_arg: str | None, - quiet: bool, -) -> _Outcome: - """The whole command as a computation returning one outcome. Nothing here - emits or exits; [`clean`] does both exactly once.""" - workspace_root, board_yaml = resolve_project_paths(project_arg, board_yaml_arg) - # tan-cli#236: `boardYaml` reported only when the file really exists. - project = Project.resolved(workspace_root, board_yaml) - resolved_sdk = resolve_sdk(sdk_root_arg, workspace_root) - sdk = SdkInfo(resolved_sdk[0], resolved_sdk[1]) if resolved_sdk else None - pin_issue = project_pin_issue(resolved_sdk[2], resolved_sdk[1]) if resolved_sdk else None - - # App base: a non-`.` positional roots the removal at that app dir, - # overriding `--project`; `.` falls back to the resolved workspace. - if app_path == ".": - project_root = workspace_root - else: - try: - cwd = os.getcwd() - except OSError: - cwd = "." - project_root = _rust_join(cwd, app_path) - - # SDK-root guard -- faithful to `alp_clean.py`'s `log.die('Cannot locate - # alp-sdk root.')`. Arguably YAGNI (removing a build dir needs no SDK), but - # the oracle keeps it, so the port keeps it. - if not sdk_root_resolves(sdk_root_arg, _cli_workspace_root(project_arg)): - message = "Cannot locate alp-sdk root." - return _Outcome( - exit_code=ExitCode.RUNTIME_FAILURE, - data=_report("", dry_run, [], 0), - project=project, - sdk=sdk, - issues=[Issue("clean.sdk-root-not-found", "error", message)], - text=[f"clean: {message}"], - ) - - # `--build-root`: absolute as-is, relative against the project root, - # default `/build`. The default is deliberately NOT - # normalized -- the oracle normalizes only the flag branch, and - # `data.buildRoot` is a compared field. - if build_root_arg is not None: - build_root = _normalize(_rust_join(project_root, build_root_arg)) - else: - build_root = _rust_join(project_root, "build") - - # Fail fast, BEFORE the manifest is read: `--build-root ""` / `.` / `..` - # each resolve to the project root or above. Refusing here is what stops - # the `rm -rf $UNSET_VAR` shape reaching a recursive removal at exit 0. - if is_unsafe_removal_target(project_root, build_root): - why = ( - f"refusing to remove `{build_root}`: a build root may not be the " - "project root, an ancestor of it, or a filesystem root" - ) - return _Outcome( - exit_code=ExitCode.RUNTIME_FAILURE, - data=_report(build_root, dry_run, [], 0), - project=project, - sdk=sdk, - issues=[Issue("clean.unsafe-build-root", "error", why)], - text=[f"clean: {why}"], - ) - - text: list[str] = [] - issues: list[Issue] = [] - if pin_issue is not None: - # tan-cli#263 review: `clean` reached the SDK guard above (something - # DID resolve), so the pin's silent fallthrough belongs in the same - # place every other non-fatal notice here lands. - issues.append(pin_issue) - - # Best-effort, manifest-aware sweep. Absence (or an unreadable file) is - # silent; a parse/version error is a warning, NEVER fatal -- clean must not - # fail over a manifest it only consults for an optimisation. - slices, manifest_error = _read_manifest(build_root) - if manifest_error is not None: - detail = f"ignoring unreadable system-manifest.yaml: {manifest_error}" - if not quiet: - text.append(f"clean: {detail}") - issues.append(Issue("clean.manifest-unreadable", "warning", detail)) - - plan = plan_clean_targets(project_root, build_root, slices) - - records: list[dict[str, str]] = [] - removed = 0 - exit_code = ExitCode.SUCCESS - - # A manifest naming a catastrophic build_dir is a broken manifest, not a - # reason to quietly clean less than asked. Report every refusal and fail. - for refused in plan.rejected: - why = refused.reason() - text.append(f"clean: {why}") - issues.append(Issue("clean.unsafe-target", "error", why)) - records.append( - {"path": refused.path, "kind": "dir", "action": "refused-unsafe"} - ) - exit_code = ExitCode.RUNTIME_FAILURE - - for target in plan.targets: - probe = Path(target) - try: - is_dir, is_file = probe.is_dir(), probe.is_file() - except OSError: - # `Path.is_dir()` swallows its own OSError, but a shape the host - # rejects outright (an over-long name, an illegal character) can - # still raise ValueError/OSError on some hosts. Treat as absent -- - # a path that cannot be probed is certainly not removed. - is_dir = is_file = False - except ValueError: - is_dir = is_file = False - kind, action = _classify(is_dir, is_file, dry_run) - - if action == "would-remove": - verb = "rmtree" if kind == "dir" else "unlink" - text.append(f"[DRY] would {verb} {target}") - records.append({"path": target, "kind": kind, "action": action}) - continue - if action == "absent": - records.append({"path": target, "kind": kind, "action": action}) - continue - - text.append(f"clean: removing {target}") - if kind == "dir": - # Best-effort, matching `rmtree(ignore_errors=True)`: a failure does - # NOT fail the command, but it IS reported -- as a warning issue and - # the `remove-failed` action -- and is not counted `removed`. The - # envelope must never claim a directory was removed when it was not. - try: - _remove_dir(target) - except (OSError, ValueError) as err: - detail = f"could not fully remove {target}: {os_error_text(err)}" - text.append(f"clean: warning: {detail}") - issues.append(Issue("clean.remove-failed", "warning", detail)) - records.append({"path": target, "kind": "dir", "action": "remove-failed"}) - else: - removed += 1 - records.append({"path": target, "kind": "dir", "action": "removed"}) - else: - # The state-file unlink is NOT ignore_errors in the Python source -- - # a failure propagates to exit 1. - try: - os.remove(target) - except (OSError, ValueError) as err: - detail = f"failed to remove {target}: {os_error_text(err)}" - issues.append(Issue("clean.remove-failed", "error", detail)) - text.append(f"clean: error: {detail}") - exit_code = ExitCode.RUNTIME_FAILURE - records.append({"path": target, "kind": "file", "action": "remove-failed"}) - else: - removed += 1 - records.append({"path": target, "kind": "file", "action": "removed"}) - - # Faithful trailing line -- suppressed under `--dry-run` (the Python source - # guards it with `and not args.dry_run`) and when a hard removal error - # already fired. - if removed == 0 and not dry_run and exit_code == ExitCode.SUCCESS: - text.append("clean: nothing to remove") - - return _Outcome( - exit_code=exit_code, - data=_report(build_root, dry_run, records, removed), - project=project, - sdk=sdk, - issues=issues, - text=text, - ) - - -def clean( - app_path: str = typer.Argument( - ".", - metavar="APP_PATH", - help=( - "Application source directory (default: '.'). The build root " - "defaults to /build; a non-'.' value overrides --project." - ), - ), - build_root: str = typer.Option( - None, - "--build-root", - metavar="PATH", - help="Override the build root to remove (default: /build).", - ), - dry_run: bool = typer.Option( - False, "--dry-run", help="List the paths that would be removed; delete nothing." - ), - project: str = typer.Option( - None, "--project", metavar="PATH", help="Project root (defaults to '.')." - ), - board_yaml: str = typer.Option( - None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path." - ), - sdk_root: str = typer.Option( - None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." - ), - output_format: str = typer.Option( - "text", "--format", metavar="FORMAT", help="Output format: text or json." - ), - quiet: bool = typer.Option( - False, "--quiet", help="Suppress the non-essential manifest notice." - ), - verbose: bool = typer.Option(False, "--verbose", hidden=True), - no_color: bool = typer.Option(False, "--no-color", hidden=True), - non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True), - ci: bool = typer.Option(False, "--ci", hidden=True), - target: str = typer.Option(None, "--target", hidden=True), - all_cores: bool = typer.Option(False, "--all", hidden=True), -) -> None: - """Remove this project's build directory and build-state cache.""" - # The six options above are `clap`'s `GlobalArgs` members that `clean.rs` - # accepts and never reads. Declared here purely so the argv SURFACE matches: - # `tan clean --no-color` exits 0 on the oracle and, without these, exited 2 as - # a Click usage error -- so a customer's `tan clean --ci` in a CI script - # cleaned nothing. Hidden from `--help` because they do nothing. - # - # This gap is PORT-WIDE, not `clean`'s alone -- measured against the Rust - # binary, `presets --no-color`, `doctor --no-color` and `sdk current --ci` - # each still exit 2 where the oracle exits 0/4/0. Closed here rather than - # left because `clean` is the command whose refusal means work that should - # have been removed was not; the shared fix (one decorator for every command) - # belongs with whoever owns the global-flag surface. - del verbose, no_color, non_interactive, ci, target, all_cores - if output_format not in ("text", "json"): - raise typer.BadParameter( - f"'{output_format}' (choose from 'text', 'json')", param_hint="--format" - ) - json_mode = output_format == "json" - - try: - outcome = _run( - app_path=app_path, - build_root_arg=build_root, - dry_run=dry_run, - project_arg=project, - board_yaml_arg=board_yaml, - sdk_root_arg=sdk_root, - quiet=quiet, - ) - except Exception as err: # noqa: BLE001 - # The recurring break this guard exists for: an escaping traceback puts - # nothing parseable on stdout and the extension renders an empty panel - # with no error at all. CONSTANTS ONLY below -- no call to a helper that - # could itself throw, which is how a single fault became a double fault - # elsewhere in this port. In particular `resolve_project_paths` is NOT - # re-run here: it reads the cwd, and a deleted cwd is one of the ways - # `_run` can fail in the first place. - outcome = _Outcome( - exit_code=ExitCode.INTERNAL_FAILURE, - data=_report("", dry_run, [], 0), - project=Project(root=None, board_yaml=None), - sdk=None, - issues=[ - Issue( - "clean.internal-failure", - "error", - f"clean failed unexpectedly: {err}", - ) - ], - text=["clean: internal failure"], - ) - - if json_mode: - emit( - Envelope( - "clean", - outcome.project, - outcome.data, - outcome.issues, - outcome.exit_code, - sdk=outcome.sdk, - ) - ) - else: - # stdout is the envelope channel and carries nothing else, in either - # mode; stderr carries no contract of its own. - for line in outcome.text: - print(line, file=sys.stderr) - raise typer.Exit(int(outcome.exit_code)) +# SPDX-License-Identifier: Apache-2.0 +"""`tan clean` -- remove this project's build root, the orchestrator's state +cache, and any out-of-tree slice build dir the system manifest names. + +Port of `crates/tan-cli/src/commands/clean.rs` plus the pure planning half it +delegates to (`tan_core::clean` + `tan_core::path_guard::is_unsafe_removal_target`). +Ported into ONE file deliberately: the pure part is ~50 lines with exactly one +consumer, and a separate `tan/core/clean.py` holding a four-line guard would be +an abstraction with a single call site. Every function names the Rust item it +mirrors. + +**This command DELETES, so the safety rules are stricter than anywhere else in +the port:** + +* Every removal candidate is screened by [`is_unsafe_removal_target`] -- the + build root included -- BEFORE any filesystem call. A candidate that IS the + project root, an ancestor of it, or a bare filesystem/drive/UNC root is + REFUSED and reported, never silently dropped and never removed. That covers + the `rm -rf $UNSET_VAR` shape: `--build-root ""`, `.` and `..` all resolve to + the project root or above, as does a manifest `build_dir: ""`. +* The screen is NOT "must stay under the build root", and must not become that. + `confine_to_build_root` -- the hardened containment guard this module DOES + reuse, see [`_subsumed_by_build_root`] -- answers a different question, and + two of the three target classes the oracle removes are legitimately OUTSIDE + the build root: the app-root `.alp-build-state.json`, and an out-of-tree slice + `build_dir` such as a Yocto tmp dir. Verified against the Rust binary: + `tan clean --build-root ../outside` removes `../outside` and exits 0. + Applying containment to every target would refuse two supported cases and + diverge from the oracle on a destructive command. The rule is "not + catastrophic", not "not outside" (`path_guard.rs:100-103`). +* A symlink or junction is never followed OUT of the tree. `shutil.rmtree` + refuses a link outright, and [`_remove_dir`] removes the LINK itself instead + -- so a `build/` junctioned at another directory unlinks the junction and + leaves its target intact (verified against the Rust binary, whose + `remove_dir_all` does the same on Windows). +* `--dry-run` reaches no removal call at all: [`_classify`] returns a + `would-remove` disposition and the removal arms are never entered. +* The build root is never guessed. An unresolvable SDK is exit 1 + (`clean.sdk-root-not-found`) and an unsafe build root is exit 1 + (`clean.unsafe-build-root`), rather than a best-effort removal of something + nearby. + +**Nothing here learns a hardware fact and nothing shells the SDK.** The +checkout is probed for its loader marker (`scripts/alp_project.py`, I-31) and +otherwise untouched: removing a build directory needs no SDK, and invoking one +would give `clean` a dependency it deliberately does not have (I-32, port-spec +anti-pattern #22). The only project input beyond the arguments is +`/system-manifest.yaml`, which this project's own build wrote. + +Every failure path emits a coded envelope. An escaping traceback puts nothing +parseable on stdout and the extension then renders an empty panel with no +error, so [`clean`]'s outer guard converts any unexpected exception into +`clean.internal-failure` at exit 5. Its recovery path builds the envelope from +constants only -- never a helper that can itself throw -- because a helper +called from the recovery path is how a single fault became a DOUBLE fault +elsewhere in this port. + +**KNOWN GAP, for whoever owns packaging.** The manifest sweep needs a YAML +parser and tan declares none; `scripts/build_binary.sh` documents the frozen +binary's build environment as `pip install typer rich pyinstaller`, so the +SHIPPED `tan clean` takes the no-PyYAML arm and emits a +`clean.manifest-unreadable` warning on every project that has ever been built +-- where the Rust oracle emits nothing. The behaviour is correct (see +[`parse_manifest_slices`]: reported, never swallowed, never fatal) but noisy. +Closing it is a packaging call -- add PyYAML to the frozen build, weighed against +the artefact-size budget `build_binary.sh` records -- and deliberately NOT a +hand-rolled fallback scanner here: a mis-parse would name a PATH handed to a +recursive removal. +""" + +from __future__ import annotations + +import os +import shutil +import stat +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import typer + +from tan.commands.build.materialise import MaterialiseError, confine_to_build_root +from tan.commands.build_cmd import resolve_sdk_root_ladder +from tan.commands.presets_cmd import resolve_project_paths, resolve_sdk +from tan.commands.sdk_cmd import SDK_MARKER, project_pin_issue +from tan.envelope import Envelope, Issue, Project, SdkInfo, emit +from tan.exit_codes import ExitCode + +#: The orchestrator state cache removed alongside the build root -- verbatim +#: `alp_clean.py`'s `targets[1]`. The orchestrator actually writes its cache at +#: `/.alp-build-state.json`, already subsumed by the recursive +#: build-root removal; this app-root path is the faithful, usually-absent target +#: the Python cleaner kept. Preserved, not "fixed" (`tan_core::clean` docs). +STATE_FILE = ".alp-build-state.json" + +#: The manifest this command sweeps for out-of-tree slice build dirs, relative +#: to the resolved build root. +MANIFEST_NAME = "system-manifest.yaml" + +#: The system-manifest schema major consumed here. A different value is a +#: warning and the manifest is IGNORED -- never read as if it were v1 +#: (`SYSTEM_MANIFEST_SCHEMA_VERSION`). +MANIFEST_SCHEMA_VERSION = 1 + + +# --------------------------------------------------------------------------- +# Pure path shape -- `tan_core::path_guard` +# --------------------------------------------------------------------------- + + +def _rust_join(base: str, rel: str) -> str: + """`PathBuf::push` semantics, which `os.path.join` does not have on Windows. + + Rust replaces the base OUTRIGHT when the right-hand side carries its own + prefix -- a drive (`C:foo`), a UNC share (`\\\\server\\share\\x`) or the + device namespace (`\\\\?\\C:\\x`). `ntpath.join` agrees for a DIFFERENT + drive but treats a drive-relative path on the SAME drive as relative to the + accumulated path, so `join("C:/proj", "C:foo")` yields `C:/proj\\foo` where + Rust yields `C:foo`. `C:foo` is one of the shapes a `--build-root` guard has + to get right, so the divergence is closed here rather than tolerated. + + Everything else defers to `os.path.join`, which already matches Rust for the + rooted-but-prefixless case (`\\rooted` keeps the base's drive) and for the + empty and `..` cases (verified against the Rust binary). On POSIX + `splitdrive` always reports no drive, so this is `os.path.join` verbatim. + """ + if os.path.splitdrive(rel)[0]: + return rel + return os.path.join(base, rel) + + +def _normalize(path: str) -> str: + """Lexically collapse `.`/`..` without touching the filesystem -- + `path_guard::normalize`. + + `os.path.normpath`, so no symlink is resolved and a path that does not exist + still normalizes. Used for COMPARISON only; a reported path keeps whatever + spelling the oracle would report. + + One known divergence, unreachable here: Rust's `normalize` pops past the + start, so a relative `..` collapses to the empty path where `normpath` + keeps `..`. Every input below is already absolute (the project root is + cwd-anchored, and the build root and slice dirs are joined onto it), so the + difference cannot be reached. + """ + return os.path.normpath(path) + + +def _has_normal_component(normalized: str) -> bool: + """Whether the path names anything at all beyond a root/prefix -- Rust's + `components().any(Component::Normal)`. + + False for `/`, `C:\\`, `C:` and a bare UNC share `\\\\server\\share`: each is + a root whose recursive removal takes out far more than a build tree. + """ + return os.path.splitdrive(normalized)[1].strip("\\/") != "" + + +def _is_under(base: str, path: str) -> bool: + """Component-wise containment on normalized paths -- Rust's + `Path::starts_with`, NOT a string prefix test. + + `/p/build` contains `/p/build/x` and itself, but NOT the sibling + `/p/build2` -- which a plain `str.startswith` would wrongly accept, and + which decides whether a slice dir is treated as a separate removal target. + Case-sensitive, matching Rust (which folds case on the Windows drive prefix + only, and both sides here come from the same join). + """ + base_n, path_n = _normalize(base), _normalize(path) + if base_n == path_n: + return True + return path_n.startswith(base_n.rstrip("\\/") + os.sep) + + +def is_unsafe_removal_target(project_root: str, target: str) -> bool: + """True when recursively removing `target` would take out far more than a + build tree, and the caller must refuse -- `path_guard::is_unsafe_removal_target`. + + Rejects a filesystem/drive/UNC root (no `Normal` component at all), and the + project root itself or any ancestor of it. Deliberately does NOT require + containment under the project root: an out-of-tree slice build dir is a + supported clean target (see the module docstring). + """ + target_n = _normalize(target) + if not _has_normal_component(target_n): + return True + # True when the target IS the project root or an ancestor of it -- both + # would delete the user's sources. + return _is_under(target_n, project_root) + + +# --------------------------------------------------------------------------- +# Pure removal planning -- `tan_core::clean` +# --------------------------------------------------------------------------- + +#: Filesystem-kind + disposition pairs, keyed by what a probe found. +_DIR_ACTIONS = {False: ("dir", "removed"), True: ("dir", "would-remove")} +_FILE_ACTIONS = {False: ("file", "removed"), True: ("file", "would-remove")} + + +@dataclass(frozen=True) +class _Rejected: + """A candidate refused as too dangerous to remove, with where it came from + so the message can name the culprit -- `clean::RejectedTarget`.""" + + path: str + #: `build-root` | `slice` -- the state file is never screened (see + #: [`plan_clean_targets`]), so it has no rejection message. + origin: str + core_id: str = "" + raw: str = "" + + def reason(self) -> str: + """One-line explanation naming the source, verbatim from + `RejectedTarget::reason`. The em dash is the oracle's own character.""" + if self.origin == "slice": + return ( + f"refusing to remove slice '{self.core_id}' build_dir " + f'"{self.raw}" (resolves to {self.path}) \u2014 it is the ' + "project root, an ancestor of it, or a filesystem root; fix " + "build/system-manifest.yaml" + ) + return ( + f"refusing to remove build root {self.path} \u2014 it is the " + "project root, an ancestor of it, or a filesystem root" + ) + + +@dataclass +class _Plan: + """`clean::CleanPlan`: paths cleared for removal (build root first), plus + everything refused. A non-empty `rejected` means the command must report and + fail -- never quietly clean less than asked.""" + + targets: list[str] = field(default_factory=list) + rejected: list[_Rejected] = field(default_factory=list) + + +def _subsumed_by_build_root(build_root: str, resolved: str) -> bool: + """Whether a slice `build_dir` is already covered by the recursive build-root + removal, so it contributes no extra target. + + Two tests, and EITHER answering "inside" is enough: + + * the oracle's lexical `clean::is_under`, which is what parity is measured + against; and + * [`confine_to_build_root`], the port's hardened containment guard, reused + here rather than re-derived -- this is the one question in `clean` whose + semantics really are "is this path confined under the build root". It + resolves both sides, so it also catches a junction or symlink inside + `build/` that points out of the tree, and the Windows shapes + (`C:foo`, `\\x`, UNC, `\\\\?\\`) a lexical test misses. + + OR, not AND, deliberately: a disagreement can then only make the port treat + a path as ALREADY COVERED, i.e. remove strictly less than the oracle -- and + the disagreement only arises when the path genuinely does live inside + `build_root`, which the recursive removal handles anyway, so the resulting + disk state is identical. Requiring both to agree would let a resolved-inside + path become a SEPARATE `shutil.rmtree` call, which is the one direction a + destructive command must not drift in. + """ + if _is_under(build_root, resolved): + return True + if not os.path.isabs(resolved): + # A DRIVE-RELATIVE leftover (`C:rel`, the one shape `_rust_join` cannot + # make absolute because Rust does not either). It cannot be containment- + # tested against an unrelated base without inventing a meaning for it: + # `Path("C:/proj/build") / "C:rel"` re-reads it as relative to the base + # and answers "inside", while Rust reports it as its own target resolved + # against drive C:'s current directory. Measured -- a manifest + # `build_dir: "C:rel"` made the oracle list an `absent` target the port + # silently dropped. The lexical answer above IS the oracle's answer here, + # and the candidate is still screened by `is_unsafe_removal_target`. + return False + try: + confine_to_build_root(Path(build_root), resolved) + except (MaterialiseError, OSError, ValueError): + # `MaterialiseError` is the escape verdict; `OSError`/`ValueError` come + # from `Path.resolve()` on a shape the host rejects outright (a device- + # namespace path, an over-long name). Either way: not proven inside. + return False + return True + + +def plan_clean_targets( + project_root: str, build_root: str, slices: list[dict[str, Any]] +) -> _Plan: + """Ordered, de-duplicated removal targets -- `clean::clean_targets`. + + 1. `build_root`, recursively. + 2. `/.alp-build-state.json`. + 3. each slice `build_dir` that lies OUTSIDE `build_root` (see + [`_subsumed_by_build_root`]); a relative value resolves against + `project_root`, an absolute one is taken as-is. + + Every candidate except the state file is then screened by + [`is_unsafe_removal_target`]. The manifest is unvalidated file content and + `build_dir: ""`, `.`, `/` or `../..` each resolve to the project root or + above; a rejected candidate goes to `rejected` so the caller can surface it + and fail, never silently dropped. The state file is exempt because it is a + single unlink of one fixed name under the project root, never a recursive + removal -- matching the oracle's own exemption. + """ + candidates: list[tuple[str, _Rejected | None]] = [ + (build_root, _Rejected(build_root, "build-root")), + (_rust_join(project_root, STATE_FILE), None), + ] + for entry in slices: + raw = entry.get("build_dir") + if not isinstance(raw, str): + continue + resolved = _rust_join(project_root, raw) + if not _subsumed_by_build_root(build_root, resolved): + core_id = entry.get("core_id", "") + candidates.append( + # `str()`: a plain-scalar `core_id: 7` is a valid String to + # serde_yaml, so it can reach the rejection message as an int. + (resolved, _Rejected(resolved, "slice", str(core_id), raw)) + ) + + plan = _Plan() + seen: list[str] = [] + for path, rejection in candidates: + key = _normalize(path) + if key in seen: + continue + seen.append(key) + if rejection is None or not is_unsafe_removal_target(project_root, path): + plan.targets.append(path) + else: + plan.rejected.append(rejection) + return plan + + +def _classify(is_dir: bool, is_file: bool, dry_run: bool) -> tuple[str, str]: + """`(kind, action)` from a probed filesystem type + the dry-run flag -- + `clean::classify`. A path that is neither a dir nor a file is `absent`: + skipped entirely, not counted, no removal attempted.""" + if is_dir: + return _DIR_ACTIONS[dry_run] + if is_file: + return _FILE_ACTIONS[dry_run] + return ("absent", "absent") + + +# --------------------------------------------------------------------------- +# The system-manifest sweep +# --------------------------------------------------------------------------- + + +#: `serde` renders an unexpected value as `` for containers and +#: <kind> `value` for scalars. Harvested from the Rust binary +#: across 25 malformed-manifest shapes, so the port's warning text matches +#: rather than approximates. +_SERDE_KIND = { + type(None): "unit value", + bool: "boolean", + int: "integer", + float: "floating point", + str: "string", + list: "sequence", + dict: "map", +} + + +def _serde_value(value: Any) -> str: + """How serde names an unexpected value in `invalid type: ...`. + + `sequence`/`map`/`unit value` carry no payload; a bool renders lowercase + (`true`), a string in double quotes, a number in backticks. + """ + kind = _SERDE_KIND.get(type(value), type(value).__name__) + if value is None or isinstance(value, (list, dict)): + return kind + if isinstance(value, bool): + return f"{kind} `{'true' if value else 'false'}`" + if isinstance(value, str): + return f'{kind} "{value}"' + return f"{kind} `{value}`" + + +def _is_yaml_scalar(value: Any) -> bool: + """Whether serde_yaml would accept this value for a `String` field. + + YAML plain scalars are untyped, and serde_yaml 0.9 hands one to whichever + visitor the target field asks for -- so `core_id: 7` deserializes into + `String` as `"7"` with no error. Verified against the Rust binary + (`core_id: 7` parses clean and the run exits 0 with no issue). A port that + demanded `isinstance(str)` here would emit a `clean.manifest-unreadable` + warning the oracle does not. + """ + return isinstance(value, (str, int, float, bool)) + + +def parse_manifest_slices(text: str) -> tuple[list[dict[str, Any]], str | None]: + """`(slices, error)` for a `system-manifest.yaml` document -- + `parse_system_manifest`, narrowed to the one field this command consumes. + + FAIL-CLOSED, matching serde: a document that is not a well-formed v1 + manifest yields NO slices and an error string, so a half-read manifest can + never hand a garbage `build_dir` to a recursive removal. Tolerant of + additive v1 fields (`deny_unknown_fields` is deliberately off upstream). + + Message text was matched against the Rust binary shape by shape; two + divergences remain and are deliberate: + + * the trailing ` at line N column M` serde_yaml appends is absent -- + `yaml.safe_load` discards node marks, and recovering them would mean a + custom composer for a warning string; + * a raw YAML SYNTAX error (a stray tab, an unclosed flow node) carries + PyYAML's wording after the shared `system-manifest is not valid YAML: ` + prefix. + + `build_dir` REQUIRES a real string, where serde_yaml would coerce a plain + scalar (`build_dir: 7` becomes `"7"` and, verified against the Rust binary, + a THIRD removal target at `/7`). Diverging here is deliberate: this + is the only manifest field that becomes a path handed to a recursive + removal, and deriving a delete target from a number in a malformed manifest + is not a behaviour worth reproducing. The port removes strictly less, the + SDK emits strings, and [`test_numeric_build_dir_is_not_turned_into_a_delete_target`] + pins it so the choice cannot drift silently. + + PyYAML is optional -- tan declares no YAML dependency -- and its absence is + REPORTED rather than swallowed: a project whose slices build out of tree + would otherwise have them left behind with no indication why. Still only a + warning; `clean` never fails over a manifest. + """ + try: + import yaml # noqa: PLC0415 (optional at runtime, by design) + except ImportError: # pragma: no cover -- present in every real workspace + return [], ( + "no YAML parser available (PyYAML is not installed), so out-of-tree " + "slice build dirs were not swept" + ) + try: + doc = yaml.safe_load(text) + except Exception as err: # noqa: BLE001 -- any parser failure, incl. the C ext + return [], f"system-manifest is not valid YAML: {err}" + + prefix = "system-manifest is not valid YAML: " + if doc is None: + # `yaml.safe_load` collapses two cases serde keeps apart: a file with no + # document at all (empty, whitespace-only, comments-only, or a bare + # `---`) has no fields, so serde reports the first REQUIRED one; an + # explicit `null`/`~` node is a real value of the wrong TYPE. Both were + # measured against the Rust binary. Told apart by whether the source + # carries any node text of its own. + body = "\n".join( + line + for line in text.splitlines() + if line.strip() not in ("", "---", "...") and not line.lstrip().startswith("#") + ) + if not body.strip(): + return [], f"{prefix}missing field `schema_version`" + return [], f"{prefix}invalid type: unit value, expected struct SystemManifest" + if not isinstance(doc, dict): + return [], f"{prefix}invalid type: {_serde_value(doc)}, expected struct SystemManifest" + + if "schema_version" not in doc: + return [], f"{prefix}missing field `schema_version`" + version = doc["schema_version"] + # `bool` is an `int` in Python but never a serde `u32`, so it is a type + # error, not a version. Checked before the int test for that reason. + if isinstance(version, bool) or not isinstance(version, int): + return [], ( + f"{prefix}schema_version: invalid type: {_serde_value(version)}, expected u32" + ) + if version != MANIFEST_SCHEMA_VERSION: + return [], ( + f"unsupported system-manifest schema_version {version} (this CLI " + f"consumes v{MANIFEST_SCHEMA_VERSION}); upgrade the CLI or the SDK " + "so the versions match" + ) + + raw = doc.get("slices", []) + if not isinstance(raw, list): + return [], f"{prefix}slices: invalid type: {_serde_value(raw)}, expected a sequence" + for index, entry in enumerate(raw): + # `core_id`/`os` are non-Option in the Rust `Slice`, so serde fails the + # WHOLE document when either is missing; `build_dir` is + # `Option`, so `null`/absent is fine and a sequence is not. A + # partial read here would act on a manifest the oracle rejects outright. + if not isinstance(entry, dict): + return [], ( + f"{prefix}slices[{index}]: invalid type: {_serde_value(entry)}, " + "expected struct Slice" + ) + for required in ("core_id", "os"): + if not _is_yaml_scalar(entry.get(required)): + missing = required not in entry or entry.get(required) is None + return [], ( + f"{prefix}slices[{index}]: missing field `{required}`" + if missing + else f"{prefix}slices[{index}].{required}: invalid type: " + f"{_serde_value(entry[required])}, expected a string" + ) + build_dir = entry.get("build_dir") + if build_dir is not None and not _is_yaml_scalar(build_dir): + return [], ( + f"{prefix}slices[{index}].build_dir: invalid type: " + f"{_serde_value(build_dir)}, expected a string" + ) + return list(raw), None + + +def _read_manifest(build_root: str) -> tuple[list[dict[str, Any]], str | None]: + """The manifest's slices, or `([], error)`. + + A READ failure -- absent, a directory in its place, a denied ACL, non-UTF-8 + bytes -- is SILENT (`([], None)`), matching the oracle's `Err(_) => None` + arm: no issue, no text, exit unchanged. Verified against the Rust binary for + both the directory and the non-UTF-8 cases. Only a document that WAS read + and could not be understood is a warning. + """ + try: + text = Path(build_root, MANIFEST_NAME).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError, ValueError): + return [], None + return parse_manifest_slices(text) + + +# --------------------------------------------------------------------------- +# Removal +# --------------------------------------------------------------------------- + + +def is_link(path: str) -> bool: + """Whether `path` is a link that must not be followed -- a POSIX symlink, a + Windows directory symlink, OR a Windows JUNCTION. + + **`os.path.islink` is not this test.** On Windows `ntpath.islink` returns + True only for `IO_REPARSE_TAG_SYMLINK`; a junction is + `IO_REPARSE_TAG_MOUNT_POINT`, and `stat.S_ISLNK` is False for it as well. + Measured on this host: for `build/` junctioned at an out-of-tree directory, + `os.path.islink` and `S_ISLNK` both report False while + `st_reparse_tag == IO_REPARSE_TAG_MOUNT_POINT`. A guard written on + `os.path.islink` therefore lets a junction reach `shutil.rmtree` -- which + has its OWN, correct check (`shutil._rmtree_islink`, mirrored here) and + refuses, so nothing outside the tree is destroyed, but the junction is then + never cleaned and the run reports a spurious `remove-failed`. This was a + live defect in the first cut of this port, caught only by diffing against + the Rust binary. + """ + try: + st = os.lstat(path) + except (OSError, ValueError): + return False + if stat.S_ISLNK(st.st_mode): + return True + attributes = getattr(st, "st_file_attributes", 0) + return bool( + attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + and getattr(st, "st_reparse_tag", 0) + == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", -1) + ) + + +def os_error_text(err: BaseException) -> str: + """An `OSError` rendered the way Rust's `io::Error` Display renders it: + ` (os error )`. + + Python's own `str(OSError)` is `[WinError 32] : ''`, which + both differs from the oracle and repeats a path the message already names. + The Windows error code (`winerror`) is preferred over the translated + `errno`, matching Rust, which reports the raw OS code. + + One character still differs on Windows: `FormatMessageW` ends its sentences + with a period and Rust keeps it, while Python's `strerror` strips it. Not + synthesized here -- guessing at punctuation inside a system message is worse + than a documented one-character divergence in a warning string. + """ + if not isinstance(err, OSError): + return str(err) + code = getattr(err, "winerror", None) or err.errno + if err.strerror is None or code is None: + return str(err) + return f"{err.strerror} (os error {code})" + + +def _retry_after_clearing_readonly(func, path, _exc=None) -> None: + """`shutil.rmtree` error hook: clear the read-only bit and retry once. + + Rust's `remove_dir_all` deletes a read-only file on Windows outright (it + passes `FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE`), where `shutil.rmtree` + fails the WHOLE tree with `[WinError 5] Access is denied`. Measured against + the Rust binary: one read-only file inside `build/` had Rust remove the + build dir and exit 0 while the port left every artefact in place and warned. + Read-only build outputs are ordinary -- some toolchains mark generated files + that way -- so this is the primary path, not an exotic one. + + `st_mode | S_IWUSR` rather than a bare `S_IWRITE`: on POSIX the latter would + replace the whole mode with `0o200` and strip the owner's read/execute bits + from a directory mid-walk. A failure here propagates out of `rmtree` and is + reported by the caller as `clean.remove-failed`. + """ + os.chmod(path, os.stat(path).st_mode | stat.S_IWUSR) + func(path) + + +#: `shutil.rmtree`'s error-hook keyword. `onerror` is deprecated from 3.12 and +#: scheduled for removal; `onexc` does not exist before it. Selected once here so +#: the call site stays a single expression on either interpreter -- the handler +#: signature is compatible because it ignores its third argument, which is the +#: only thing the two hooks disagree about (`exc_info` tuple vs exception). +_RMTREE_HOOK = "onexc" if sys.version_info >= (3, 12) else "onerror" + + +def _remove_dir(path: str) -> None: + """Remove a directory target recursively, never following a link out of the + tree. + + A link ([`is_link`]) is unlinked ITSELF, exactly as the oracle's + `remove_dir_all` does on Windows: verified against the Rust binary with + `build/` junctioned at an out-of-tree directory -- the junction goes, the + target's contents stay. `shutil.rmtree` handles the ordinary case and never + recurses through a link INSIDE the tree, so both arms are contained. + + `os.rmdir` before `os.unlink`: on Windows a junction or directory symlink is + removed by `RemoveDirectory`, and `unlink` fails on it; on POSIX `rmdir` + fails on a symlink and `unlink` is what removes it. + """ + if is_link(path): + try: + os.rmdir(path) + except OSError: + os.unlink(path) + return + shutil.rmtree(path, **{_RMTREE_HOOK: _retry_after_clearing_readonly}) + + +# --------------------------------------------------------------------------- +# SDK resolution +# --------------------------------------------------------------------------- + + +def _cli_workspace_root(project_arg: str | None) -> Path: + """`util::cli_workspace_root`: `--project` joined to the cwd, UNNORMALIZED + (the oracle normalizes only for the reported `project.root`), or the cwd + itself when the flag is absent. Feeds the SDK guard's discovery walk, whose + sibling/ancestor probes are lexical -- so the unnormalized spelling is what + keeps the two implementations probing the same directories.""" + try: + cwd = os.getcwd() + except OSError: + cwd = "." # `current_dir().unwrap_or_else(|_| PathBuf::from("."))` + return Path(cwd if project_arg is None else _rust_join(cwd, project_arg)) + + +def sdk_root_resolves(sdk_root: str | None, workspace_root: Path) -> bool: + """Whether `build_cmd.resolve_sdk_root_ladder` would resolve a checkout -- + the guard behind `clean.sdk-root-not-found`. + + `--sdk-root` is TERMINAL (I-31): an explicit path without the loader marker + fails here rather than falling through to a lower tier and cleaning against + a checkout the caller never named -- checked explicitly below because the + ladder itself returns a `--sdk-root` value unvalidated (matching the + oracle's `resolve_sdk_tiered`, terminal for REPORTING); this gate matches + `util::resolve_sdk_root`, terminal AND validated. The project-pin and + global-default tiers are best-effort, so a stale pointer falls through + instead of locking the user out. + + The ladder's LAST tier is the wide positional walk (root, child `alp-sdk`, + sibling `alp-sdk`, sibling `alp-sdk-upstream`, then ancestors; first match + wins), but it is reached only when the narrower `resolve_sdk_tiered` + discovery tier AHEAD of it answers `None` -- a narrow hit short-circuits. + So in a workspace holding BOTH a child `alp-sdk` and a lateral one, what + gates this command is the lateral checkout, not the child, and the wide + walk never runs (measured against the oracle: `tan clean` there resolves + `../alp-sdk` too, tan-cli#263). + + That ordering does not move this boolean: every candidate the narrow tier + probes is also one the wide walk probes, so a narrow hit implies a wide + hit. What the wide tail still buys is the case the narrow tier cannot + answer -- a `tan bootstrap` workspace whose checkout is a CHILD of the cwd, + where narrow returns `None` and, without the tail, `tan clean` would refuse + to run (tan-cli#218; measured: the oracle resolves `/alp-sdk` there). + + Note this gate and the REPORTED `sdk` key are still two different + resolutions: [`resolve_sdk`][tan.commands.presets_cmd.resolve_sdk] (below) + reports through `resolve_sdk_tiered` alone, so a bootstrap-child workspace + gates open here while reporting no `sdk` at all. + """ + resolved, tier, _broken_pin = resolve_sdk_root_ladder(sdk_root, workspace_root) + if resolved is None: + return False + if tier == "sdkRootFlag": + return resolved.joinpath(*SDK_MARKER).exists() + return True + + +# --------------------------------------------------------------------------- +# Envelope assembly +# --------------------------------------------------------------------------- + + +@dataclass +class _Outcome: + """What one run produced. Built and returned, never emitted in place, so the + exception guard in [`clean`] can wrap the whole computation without also + catching `typer.Exit` (a `RuntimeError` subclass, not a `SystemExit`, which a + bare `except Exception` would otherwise swallow).""" + + exit_code: ExitCode + data: dict[str, Any] + project: Project + sdk: SdkInfo | None + issues: list[Issue] + text: list[str] + + +def _report(build_root: str, dry_run: bool, targets: list[dict[str, str]], removed: int): + return { + "buildRoot": build_root, + "dryRun": dry_run, + "targets": targets, + "removed": removed, + } + + +def _run( + *, + app_path: str, + build_root_arg: str | None, + dry_run: bool, + project_arg: str | None, + board_yaml_arg: str | None, + sdk_root_arg: str | None, + quiet: bool, +) -> _Outcome: + """The whole command as a computation returning one outcome. Nothing here + emits or exits; [`clean`] does both exactly once.""" + workspace_root, board_yaml = resolve_project_paths(project_arg, board_yaml_arg) + # tan-cli#236: `boardYaml` reported only when the file really exists. + project = Project.resolved(workspace_root, board_yaml) + resolved_sdk = resolve_sdk(sdk_root_arg, workspace_root) + sdk = SdkInfo(resolved_sdk[0], resolved_sdk[1]) if resolved_sdk else None + pin_issue = project_pin_issue(resolved_sdk[2], resolved_sdk[1]) if resolved_sdk else None + + # App base: a non-`.` positional roots the removal at that app dir, + # overriding `--project`; `.` falls back to the resolved workspace. + if app_path == ".": + project_root = workspace_root + else: + try: + cwd = os.getcwd() + except OSError: + cwd = "." + project_root = _rust_join(cwd, app_path) + + # SDK-root guard -- faithful to `alp_clean.py`'s `log.die('Cannot locate + # alp-sdk root.')`. Arguably YAGNI (removing a build dir needs no SDK), but + # the oracle keeps it, so the port keeps it. + if not sdk_root_resolves(sdk_root_arg, _cli_workspace_root(project_arg)): + message = "Cannot locate alp-sdk root." + return _Outcome( + exit_code=ExitCode.RUNTIME_FAILURE, + data=_report("", dry_run, [], 0), + project=project, + sdk=sdk, + issues=[Issue("clean.sdk-root-not-found", "error", message)], + text=[f"clean: {message}"], + ) + + # `--build-root`: absolute as-is, relative against the project root, + # default `/build`. The default is deliberately NOT + # normalized -- the oracle normalizes only the flag branch, and + # `data.buildRoot` is a compared field. + if build_root_arg is not None: + build_root = _normalize(_rust_join(project_root, build_root_arg)) + else: + build_root = _rust_join(project_root, "build") + + # Fail fast, BEFORE the manifest is read: `--build-root ""` / `.` / `..` + # each resolve to the project root or above. Refusing here is what stops + # the `rm -rf $UNSET_VAR` shape reaching a recursive removal at exit 0. + if is_unsafe_removal_target(project_root, build_root): + why = ( + f"refusing to remove `{build_root}`: a build root may not be the " + "project root, an ancestor of it, or a filesystem root" + ) + return _Outcome( + exit_code=ExitCode.RUNTIME_FAILURE, + data=_report(build_root, dry_run, [], 0), + project=project, + sdk=sdk, + issues=[Issue("clean.unsafe-build-root", "error", why)], + text=[f"clean: {why}"], + ) + + text: list[str] = [] + issues: list[Issue] = [] + if pin_issue is not None: + # tan-cli#263 review: `clean` reached the SDK guard above (something + # DID resolve), so the pin's silent fallthrough belongs in the same + # place every other non-fatal notice here lands. + issues.append(pin_issue) + + # Best-effort, manifest-aware sweep. Absence (or an unreadable file) is + # silent; a parse/version error is a warning, NEVER fatal -- clean must not + # fail over a manifest it only consults for an optimisation. + slices, manifest_error = _read_manifest(build_root) + if manifest_error is not None: + detail = f"ignoring unreadable system-manifest.yaml: {manifest_error}" + if not quiet: + text.append(f"clean: {detail}") + issues.append(Issue("clean.manifest-unreadable", "warning", detail)) + + plan = plan_clean_targets(project_root, build_root, slices) + + records: list[dict[str, str]] = [] + removed = 0 + exit_code = ExitCode.SUCCESS + + # A manifest naming a catastrophic build_dir is a broken manifest, not a + # reason to quietly clean less than asked. Report every refusal and fail. + for refused in plan.rejected: + why = refused.reason() + text.append(f"clean: {why}") + issues.append(Issue("clean.unsafe-target", "error", why)) + records.append( + {"path": refused.path, "kind": "dir", "action": "refused-unsafe"} + ) + exit_code = ExitCode.RUNTIME_FAILURE + + for target in plan.targets: + probe = Path(target) + try: + is_dir, is_file = probe.is_dir(), probe.is_file() + except OSError: + # `Path.is_dir()` swallows its own OSError, but a shape the host + # rejects outright (an over-long name, an illegal character) can + # still raise ValueError/OSError on some hosts. Treat as absent -- + # a path that cannot be probed is certainly not removed. + is_dir = is_file = False + except ValueError: + is_dir = is_file = False + kind, action = _classify(is_dir, is_file, dry_run) + + if action == "would-remove": + verb = "rmtree" if kind == "dir" else "unlink" + text.append(f"[DRY] would {verb} {target}") + records.append({"path": target, "kind": kind, "action": action}) + continue + if action == "absent": + records.append({"path": target, "kind": kind, "action": action}) + continue + + text.append(f"clean: removing {target}") + if kind == "dir": + # Best-effort, matching `rmtree(ignore_errors=True)`: a failure does + # NOT fail the command, but it IS reported -- as a warning issue and + # the `remove-failed` action -- and is not counted `removed`. The + # envelope must never claim a directory was removed when it was not. + try: + _remove_dir(target) + except (OSError, ValueError) as err: + detail = f"could not fully remove {target}: {os_error_text(err)}" + text.append(f"clean: warning: {detail}") + issues.append(Issue("clean.remove-failed", "warning", detail)) + records.append({"path": target, "kind": "dir", "action": "remove-failed"}) + else: + removed += 1 + records.append({"path": target, "kind": "dir", "action": "removed"}) + else: + # The state-file unlink is NOT ignore_errors in the Python source -- + # a failure propagates to exit 1. + try: + os.remove(target) + except (OSError, ValueError) as err: + detail = f"failed to remove {target}: {os_error_text(err)}" + issues.append(Issue("clean.remove-failed", "error", detail)) + text.append(f"clean: error: {detail}") + exit_code = ExitCode.RUNTIME_FAILURE + records.append({"path": target, "kind": "file", "action": "remove-failed"}) + else: + removed += 1 + records.append({"path": target, "kind": "file", "action": "removed"}) + + # Faithful trailing line -- suppressed under `--dry-run` (the Python source + # guards it with `and not args.dry_run`) and when a hard removal error + # already fired. + if removed == 0 and not dry_run and exit_code == ExitCode.SUCCESS: + text.append("clean: nothing to remove") + + return _Outcome( + exit_code=exit_code, + data=_report(build_root, dry_run, records, removed), + project=project, + sdk=sdk, + issues=issues, + text=text, + ) + + +def clean( + app_path: str = typer.Argument( + ".", + metavar="APP_PATH", + help=( + "Application source directory (default: '.'). The build root " + "defaults to /build; a non-'.' value overrides --project." + ), + ), + build_root: str = typer.Option( + None, + "--build-root", + metavar="PATH", + help="Override the build root to remove (default: /build).", + ), + dry_run: bool = typer.Option( + False, "--dry-run", help="List the paths that would be removed; delete nothing." + ), + project: str = typer.Option( + None, "--project", metavar="PATH", help="Project root (defaults to '.')." + ), + board_yaml: str = typer.Option( + None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path." + ), + sdk_root: str = typer.Option( + None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." + ), + output_format: str = typer.Option( + "text", "--format", metavar="FORMAT", help="Output format: text or json." + ), + quiet: bool = typer.Option( + False, "--quiet", help="Suppress the non-essential manifest notice." + ), + verbose: bool = typer.Option(False, "--verbose", hidden=True), + no_color: bool = typer.Option(False, "--no-color", hidden=True), + non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True), + ci: bool = typer.Option(False, "--ci", hidden=True), + target: str = typer.Option(None, "--target", hidden=True), + all_cores: bool = typer.Option(False, "--all", hidden=True), +) -> None: + """Remove this project's build directory and build-state cache.""" + # The six options above are `clap`'s `GlobalArgs` members that `clean.rs` + # accepts and never reads. Declared here purely so the argv SURFACE matches: + # `tan clean --no-color` exits 0 on the oracle and, without these, exited 2 as + # a Click usage error -- so a customer's `tan clean --ci` in a CI script + # cleaned nothing. Hidden from `--help` because they do nothing. + # + # This gap is PORT-WIDE, not `clean`'s alone -- measured against the Rust + # binary, `presets --no-color`, `doctor --no-color` and `sdk current --ci` + # each still exit 2 where the oracle exits 0/4/0. Closed here rather than + # left because `clean` is the command whose refusal means work that should + # have been removed was not; the shared fix (one decorator for every command) + # belongs with whoever owns the global-flag surface. + del verbose, no_color, non_interactive, ci, target, all_cores + if output_format not in ("text", "json"): + raise typer.BadParameter( + f"'{output_format}' (choose from 'text', 'json')", param_hint="--format" + ) + json_mode = output_format == "json" + + try: + outcome = _run( + app_path=app_path, + build_root_arg=build_root, + dry_run=dry_run, + project_arg=project, + board_yaml_arg=board_yaml, + sdk_root_arg=sdk_root, + quiet=quiet, + ) + except Exception as err: # noqa: BLE001 + # The recurring break this guard exists for: an escaping traceback puts + # nothing parseable on stdout and the extension renders an empty panel + # with no error at all. CONSTANTS ONLY below -- no call to a helper that + # could itself throw, which is how a single fault became a double fault + # elsewhere in this port. In particular `resolve_project_paths` is NOT + # re-run here: it reads the cwd, and a deleted cwd is one of the ways + # `_run` can fail in the first place. + outcome = _Outcome( + exit_code=ExitCode.INTERNAL_FAILURE, + data=_report("", dry_run, [], 0), + project=Project(root=None, board_yaml=None), + sdk=None, + issues=[ + Issue( + "clean.internal-failure", + "error", + f"clean failed unexpectedly: {err}", + ) + ], + text=["clean: internal failure"], + ) + + if json_mode: + emit( + Envelope( + "clean", + outcome.project, + outcome.data, + outcome.issues, + outcome.exit_code, + sdk=outcome.sdk, + ) + ) + else: + # stdout is the envelope channel and carries nothing else, in either + # mode; stderr carries no contract of its own. + for line in outcome.text: + print(line, file=sys.stderr) + raise typer.Exit(int(outcome.exit_code)) diff --git a/python/tan/commands/completion_cmd.py b/python/tan/commands/completion_cmd.py new file mode 100644 index 00000000..06bd16e5 --- /dev/null +++ b/python/tan/commands/completion_cmd.py @@ -0,0 +1,522 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan completion` -- emit a shell completion script for bash, zsh, or fish. + +Mirrors `crates/tan-cli/src/commands/completion.rs`. The three scripts below +are embedded verbatim: byte-for-byte captures of the reference Rust oracle's +own `data.script` field (`target/debug/tan.exe completion --shell + --format json`), the same "captured, not generated" contract +the oracle's own module docstring describes (there it is `include_str!` over a +committed `.bash`/`.zsh`/`.fish` file; here it is a literal, since this unit's +file allowlist is `completion_cmd.py` alone). + +**Why hand-captured and not Typer/Click's own shell-completion machinery.** +Typer ships one (`click.shell_completion`, gated off here via `app = +typer.Typer(add_completion=False)` in `cli.py`), but it is not a substitute: +it activates through a completely different mechanism -- sourcing eval output +from an `_TAN_COMPLETE=_source tan` environment-variable trigger +Click's own dispatcher special-cases at import time, not a static script this +command prints -- and it introspects THIS PROCESS's live Click command tree +rather than emitting the oracle's fixed command/flag tables. Even +functionally equivalent tab-completion from it would not reproduce +`data.script` byte-for-byte, which is the wire contract this command's JSON +envelope carries (an extension or script that diffs/hashes that field would +see every invocation as a regression). Hand-captured scripts are therefore +the only way to be a faithful port here, not a shortcut around one. + +Every failure is an envelope, never a traceback: the ONLY failure this +command has is an unsupported `--shell` value, mirroring `resolve_shell` in +the Rust 1:1 (default `bash`; trim + lowercase; anything else is +`completion.shell-unsupported`, exit `RUNTIME_FAILURE`). There is no project +resolution, no SDK checkout, no filesystem read and no subprocess -- `project` +stays `null`/`null` in every envelope, matching the oracle's `null_project()`. +""" + +from __future__ import annotations + +import sys + +import typer + +from tan.envelope import Envelope, Issue, Project, emit +from tan.exit_codes import ExitCode + +#: `data.schemaVersion` for this command's payload. +DATA_SCHEMA_VERSION = "1" + +#: The frozen issue code for an unsupported `--shell` value +#: (`contract/issue-codes.json`; `emittedBy` there already names this file). +SHELL_UNSUPPORTED_CODE = "completion.shell-unsupported" +#: Verbatim from `completion.rs`'s `Issue.message` -- the JSON-mode wording. +SHELL_UNSUPPORTED_MESSAGE = "Unsupported shell. Allowed values: bash, zsh, fish." +#: Verbatim from `completion.rs`'s `text` line -- the text-mode (stderr) wording. +SHELL_UNSUPPORTED_TEXT_LINE = "completion: unsupported shell. Use --shell bash|zsh|fish." + +#: Verbatim bash completion script, captured from the reference oracle. +BASH_SCRIPT = """# tan CLI bash completion +_tan_complete() { + local cur prev words cword + + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + cword=${COMP_CWORD} + + local commands="validate generate init scaffold examples doctor completion diff presets pinmux explain inspect trace debug-config support-bundle sdk bootstrap build kconfig image flash run clean renode size migrate lock quality model monitor new-som faultdecode" + local global_flags="--project --board-yaml --sdk-root --target --all --format --verbose --quiet --no-color --non-interactive --ci --help --version" + + if [[ "$prev" == "--format" ]]; then + COMPREPLY=( $(compgen -W "text json" -- "$cur") ) + return + fi + + if [[ "$prev" == "--shell" ]]; then + COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) + return + fi + + if [[ $cword -eq 1 ]]; then + COMPREPLY=( $(compgen -W "$commands $global_flags" -- "$cur") ) + return + fi + + case "${COMP_WORDS[1]}" in + validate) + COMPREPLY=( $(compgen -W "$global_flags --offline" -- "$cur") ) + ;; + generate) + COMPREPLY=( $(compgen -W "$global_flags --force --core" -- "$cur") ) + ;; + explain) + COMPREPLY=( $(compgen -W "$global_flags --template" -- "$cur") ) + ;; + init) + COMPREPLY=( $(compgen -W "$global_flags --template --from-example --name --destination --som --cores --preview --force" -- "$cur") ) + ;; + scaffold) + COMPREPLY=( $(compgen -W "$global_flags --template --name --destination --preview --force" -- "$cur") ) + ;; + diff|presets) + COMPREPLY=( $(compgen -W "$global_flags" -- "$cur") ) + ;; + examples) + COMPREPLY=( $(compgen -W "$global_flags --filter" -- "$cur") ) + ;; + completion) + COMPREPLY=( $(compgen -W "$global_flags --shell" -- "$cur") ) + ;; + pinmux) + COMPREPLY=( $(compgen -W "$global_flags --sku --family" -- "$cur") ) + ;; + doctor) + COMPREPLY=( $(compgen -W "$global_flags --target-kind --server --build --fix" -- "$cur") ) + ;; + inspect) + COMPREPLY=( $(compgen -W "$global_flags --path --show-origin" -- "$cur") ) + ;; + trace) + COMPREPLY=( $(compgen -W "$global_flags --path" -- "$cur") ) + ;; + debug-config) + COMPREPLY=( $(compgen -W "$global_flags --target-kind --server --core --pre-launch-task --svd --preview" -- "$cur") ) + ;; + support-bundle) + COMPREPLY=( $(compgen -W "$global_flags --destination --target-kind --server --path" -- "$cur") ) + ;; + sdk) + COMPREPLY=( $(compgen -W "$global_flags list install current switch --destination --global" -- "$cur") ) + ;; + bootstrap) + COMPREPLY=( $(compgen -W "$global_flags --no-pip --no-west --print-env --allow-partial --workspace" -- "$cur") ) + ;; + build) + COMPREPLY=( $(compgen -W "$global_flags --plan --plan-from --materialise --native --manifest --manifest-from --no-auto-bootstrap --pristine" -- "$cur") ) + ;; + kconfig) + COMPREPLY=( $(compgen -W "$global_flags --core" -- "$cur") ) + ;; + image) + COMPREPLY=( $(compgen -W "$global_flags --build-root" -- "$cur") ) + ;; + flash) + COMPREPLY=( $(compgen -W "$global_flags --build-root --dry-run --core --helper --skip-missing-tools" -- "$cur") ) + ;; + run) + COMPREPLY=( $(compgen -W "$global_flags --flash --core" -- "$cur") ) + ;; + clean) + COMPREPLY=( $(compgen -W "$global_flags --build-root --dry-run" -- "$cur") ) + ;; + renode) + COMPREPLY=( $(compgen -W "$global_flags --build-root --board --core --image-bundle --log --timeout --expect --sim-mode" -- "$cur") ) + ;; + size) + COMPREPLY=( $(compgen -W "$global_flags --build-root --board --fail-over-budget" -- "$cur") ) + ;; + *) + COMPREPLY=( $(compgen -W "$global_flags" -- "$cur") ) + ;; + esac +} + +complete -F _tan_complete tan +""" + +#: Verbatim zsh completion script, captured from the reference oracle. +ZSH_SCRIPT = """#compdef tan + +_tan() { + local -a commands + commands=( + 'validate:Validate board.yaml config' + 'generate:Generate derived artifacts' + 'init:Initialize a starter project' + 'scaffold:Scaffold module files' + 'examples:List SDK example projects' + 'doctor:Run debug and environment checks' + 'completion:Generate shell completion script' + 'diff:Show board normalization diff' + 'presets:List SDK presets' + 'pinmux:Show pinmux capability table' + 'explain:Explain templates and targets' + 'inspect:Inspect effective resolved values' + 'trace:Trace generation decisions' + 'debug-config:Generate a launch.json debug configuration' + 'support-bundle:Export support bundle payload' + 'sdk:Manage local SDK installs' + 'bootstrap:Set up the SDK build environment' + 'build:Build the project natively' + 'kconfig:Show the board-scoped Kconfig symbol menu' + 'image:Assemble a flashable image bundle' + 'flash:Flash slices and helper MCUs onto the device' + 'run:Build then run the project' + 'clean:Remove the build dir and state cache' + 'renode:Boot the built manifest in headless Renode' + 'size:Report per-slice firmware footprint' + 'migrate:Migrate board.yaml to the current schema' + 'lock:Pin/lock library dependencies' + 'quality:Run board.yaml quality checks' + 'model:Compile and package board.yaml models' + 'monitor:Open a serial console to the board' + 'new-som:Scaffold a new SoM metadata skeleton' + 'faultdecode:Decode an ARM Cortex-M fault dump' + ) + + # Every flag `GlobalArgs` marks `global = true` (cli.rs) is accepted by + # clap on EVERY subcommand — AND on the root command itself, before any + # subcommand word is even typed — so every arm below splices this in, and + # so does the root `_arguments -C` call a few lines down. Unlike bash's + # single `$global_flags` string var, zsh's per-arm `_arguments` has no + # inheritance of its own (issue #92 MAJOR 2) — a flag left out of an arm + # here is simply not completable for that subcommand (or, left out of the + # root call, not completable at `tan --` before a subcommand: issue + # #92 round-3 FINDING 1). + local -a global_args + global_args=( + '--project[Project root]:path:_files -/' + '--board-yaml[board.yaml path]:path:_files' + '--sdk-root[SDK root]:path:_files -/' + '--target[Generation target]' + '--all[Generate all targets]' + '--format[Output format]:format:(text json)' + '--verbose[Verbose output]' + '--quiet[Quiet output]' + '--no-color[Disable color output]' + '--non-interactive[Disable prompts]' + '--ci[CI mode]' + '--help[Show help]' + '--version[Show version]' + ) + + _arguments -C '1:command:->command' '*::arg:->args' "${global_args[@]}" + + case $state in + command) + _describe 'command' commands + ;; + args) + case $words[2] in + validate) + _arguments '--offline[Offline structural validation only]' "${global_args[@]}" + ;; + completion) + _arguments '--shell[Shell type]:shell:(bash zsh fish)' "${global_args[@]}" + ;; + generate) + _arguments '--force[Overwrite existing files]' '--core[Core id (zephyr-board target)]' "${global_args[@]}" + ;; + explain) + _arguments '--template[Template id]' "${global_args[@]}" + ;; + examples) + _arguments '--filter[Substring match on id/title]' "${global_args[@]}" + ;; + init) + _arguments '--template[Template id]' '--from-example[Example source dir]' '--name[Name value]' '--destination[Output directory]:path:_files -/' '--som[SoM SKU]' '--cores[Cores list]' '--preview[Preview only]' '--force[Overwrite existing files]' "${global_args[@]}" + ;; + scaffold) + _arguments '--template[Template id]' '--name[Name value]' '--destination[Output directory]:path:_files -/' '--preview[Preview only]' '--force[Overwrite existing files]' "${global_args[@]}" + ;; + pinmux) + _arguments '--sku[SoM SKU]' '--family[Pinmux family]' "${global_args[@]}" + ;; + doctor) + _arguments '--target-kind[Debug target]:target:(zephyr-mcu baremetal-mcu yocto-userspace native-host)' '--server[Debug server]:server:(jlink openocd pyocd gdbserver none)' '--build[Build readiness preflight]' '--fix[Auto-repair a fixable blocker]' "${global_args[@]}" + ;; + inspect) + _arguments '--path[Field path]' '--show-origin[Include source metadata]' "${global_args[@]}" + ;; + trace) + _arguments '--path[Field path]' "${global_args[@]}" + ;; + debug-config) + _arguments '--target-kind[Debug target]:target:(zephyr-mcu baremetal-mcu yocto-userspace native-host)' '--server[Debug server]:server:(jlink openocd pyocd gdbserver none)' '--core[Build slice core id]' '--pre-launch-task[VS Code task to run before launching]' '--svd[Path to a user-supplied SVD for the peripheral view]:svd:_files -g "*.svd"' '--preview[Preview only]' "${global_args[@]}" + ;; + support-bundle) + _arguments '--destination[Output directory]:path:_files -/' '--target-kind[Debug target]:target:(zephyr-mcu baremetal-mcu yocto-userspace native-host)' '--server[Debug server]:server:(jlink openocd pyocd gdbserver none)' '--path[Field path]' "${global_args[@]}" + ;; + sdk) + _arguments '1:subcommand:(list install current switch)' '--destination[Cache root]:path:_files -/' '--global[Pin the machine-global default]' "${global_args[@]}" + ;; + bootstrap) + _arguments '--no-pip[Skip pip install]' '--no-west[Skip west init/update]' '--print-env[Print environment lines only]' '--allow-partial[Report success despite a failed dependency install]' '--workspace[Build the workspace at this path]:path:_files -/' "${global_args[@]}" + ;; + build) + _arguments '--plan[Show the build plan]' '--plan-from[Read build plan from file]:path:_files' '--materialise[Materialise plan files]' '--native[Build natively]' '--manifest[Show the system manifest]' '--manifest-from[Read manifest from file]:path:_files' '--no-auto-bootstrap[Never bootstrap implicitly]' '--pristine[Force-wipe build dirs before dispatch]' "${global_args[@]}" + ;; + kconfig) + _arguments '--core[Core id to scope the menu to]' "${global_args[@]}" + ;; + image) + _arguments '--build-root[Override build root]:path:_files -/' "${global_args[@]}" + ;; + flash) + _arguments '--build-root[Override build root]:path:_files -/' '--dry-run[Print planned commands only]' '--core[Flash only this core]' '--helper[Flash only this helper MCU]' '--skip-missing-tools[Skip entries with no tool on PATH]' "${global_args[@]}" + ;; + run) + _arguments '--flash[Flash the board after building]' '--core[Flash only this core]' "${global_args[@]}" + ;; + clean) + _arguments '--build-root[Override build root]:path:_files -/' '--dry-run[List targets without removing]' "${global_args[@]}" + ;; + renode) + _arguments '--build-root[Override build root]:path:_files -/' '--board[Override SoM SKU]' '--core[Zephyr slice core id]' '--image-bundle[Pre-built artefacts dir]:path:_files -/' '--log[Console log file]:path:_files' '--timeout[Wall-clock cap in seconds]' '--expect[Stop early on this substring]' '--sim-mode[Studio hardware-simulator mode]' "${global_args[@]}" + ;; + size) + _arguments '--build-root[Override build root]:path:_files -/' '--board[Override SoM SKU]' '--fail-over-budget[Exit non-zero over budget]' "${global_args[@]}" + ;; + *) + _arguments "${global_args[@]}" + ;; + esac + ;; + esac +} + +compdef _tan tan +""" + +#: Verbatim fish completion script, captured from the reference oracle. +FISH_SCRIPT = """complete -c tan -f +complete -c tan -n '__fish_use_subcommand' -a 'validate generate init scaffold examples doctor completion diff presets pinmux explain inspect trace debug-config support-bundle sdk bootstrap build kconfig image flash run clean renode size migrate lock quality model monitor new-som faultdecode' +complete -c tan -l project -d 'Project root' +complete -c tan -l board-yaml -d 'board.yaml path' +complete -c tan -l sdk-root -d 'SDK root path' +complete -c tan -l target -d 'Generation target' -a 'zephyr-conf dts-overlay native-sim-overlay cmake-args yocto-conf carrier-netlist west-libraries zephyr-board hw-info-h' +complete -c tan -l all -d 'Generate all targets' +complete -c tan -l format -d 'Output format' -a 'text json' +complete -c tan -l verbose -d 'Verbose output' +complete -c tan -l quiet -d 'Quiet output' +complete -c tan -l no-color -d 'Disable color output' +complete -c tan -l non-interactive -d 'Disable prompts' +complete -c tan -l ci -d 'CI mode' +complete -c tan -l help -d 'Show help' +complete -c tan -l version -d 'Show version' +complete -c tan -n '__fish_seen_subcommand_from validate' -l offline -d 'Offline structural validation only' +complete -c tan -n '__fish_seen_subcommand_from generate' -l force -d 'Overwrite existing files' +complete -c tan -n '__fish_seen_subcommand_from generate' -l core -d 'Core id (zephyr-board target)' +complete -c tan -n '__fish_seen_subcommand_from explain' -l template -d 'Template id' +complete -c tan -n '__fish_seen_subcommand_from examples' -l filter -d 'Substring match on id/title' +complete -c tan -n '__fish_seen_subcommand_from init scaffold' -l template -d 'Template id' +complete -c tan -n '__fish_seen_subcommand_from init' -l from-example -d 'Example source dir' +complete -c tan -n '__fish_seen_subcommand_from init scaffold' -l name -d 'Name value' +complete -c tan -n '__fish_seen_subcommand_from init scaffold' -l destination -d 'Destination path' +complete -c tan -n '__fish_seen_subcommand_from init' -l som -d 'SoM SKU' +complete -c tan -n '__fish_seen_subcommand_from init' -l cores -d 'Cores list' +complete -c tan -n '__fish_seen_subcommand_from init scaffold' -l preview -d 'Preview only' +complete -c tan -n '__fish_seen_subcommand_from init scaffold' -l force -d 'Overwrite existing files' +complete -c tan -n '__fish_seen_subcommand_from pinmux' -l sku -d 'SoM SKU' +complete -c tan -n '__fish_seen_subcommand_from pinmux' -l family -d 'Pinmux family' +complete -c tan -n '__fish_seen_subcommand_from doctor' -l target-kind -d 'Debug target kind' -a 'zephyr-mcu baremetal-mcu yocto-userspace native-host' +complete -c tan -n '__fish_seen_subcommand_from doctor' -l server -d 'Debug server' -a 'jlink openocd pyocd gdbserver none' +complete -c tan -n '__fish_seen_subcommand_from doctor' -l build -d 'Build readiness preflight' +complete -c tan -n '__fish_seen_subcommand_from doctor' -l fix -d 'Auto-repair a fixable blocker' +complete -c tan -n '__fish_seen_subcommand_from inspect trace support-bundle' -l path -d 'Field path' +complete -c tan -n '__fish_seen_subcommand_from inspect' -l show-origin -d 'Include source metadata' +complete -c tan -n '__fish_seen_subcommand_from debug-config' -l target-kind -d 'Debug target kind' -a 'zephyr-mcu baremetal-mcu yocto-userspace native-host' +complete -c tan -n '__fish_seen_subcommand_from debug-config' -l server -d 'Debug server' -a 'jlink openocd pyocd gdbserver none' +complete -c tan -n '__fish_seen_subcommand_from debug-config' -l core -d 'Build slice core id' +complete -c tan -n '__fish_seen_subcommand_from debug-config' -l pre-launch-task -d 'VS Code task to run before launching' +complete -c tan -n '__fish_seen_subcommand_from debug-config' -l svd -r -d 'Path to a user-supplied SVD for the peripheral view' +complete -c tan -n '__fish_seen_subcommand_from debug-config' -l preview -d 'Preview only' +complete -c tan -n '__fish_seen_subcommand_from support-bundle' -l destination -d 'Destination path' +complete -c tan -n '__fish_seen_subcommand_from support-bundle' -l target-kind -d 'Debug target kind' -a 'zephyr-mcu baremetal-mcu yocto-userspace native-host' +complete -c tan -n '__fish_seen_subcommand_from support-bundle' -l server -d 'Debug server' -a 'jlink openocd pyocd gdbserver none' +complete -c tan -n '__fish_seen_subcommand_from completion' -l shell -d 'Shell type' -a 'bash zsh fish' +complete -c tan -n '__fish_seen_subcommand_from sdk' -a 'list install current switch' +complete -c tan -n '__fish_seen_subcommand_from sdk' -l destination -d 'Cache root' +complete -c tan -n '__fish_seen_subcommand_from sdk' -l global -d 'Pin the machine-global default' +complete -c tan -n '__fish_seen_subcommand_from bootstrap' -l no-pip -d 'Skip pip install' +complete -c tan -n '__fish_seen_subcommand_from bootstrap' -l no-west -d 'Skip west init/update' +complete -c tan -n '__fish_seen_subcommand_from bootstrap' -l print-env -d 'Print environment lines only' +complete -c tan -n '__fish_seen_subcommand_from bootstrap' -l allow-partial -d 'Report success despite a failed dependency install' +complete -c tan -n '__fish_seen_subcommand_from bootstrap' -l workspace -d 'Build the workspace at this path' +complete -c tan -n '__fish_seen_subcommand_from build' -l plan -d 'Show the build plan' +complete -c tan -n '__fish_seen_subcommand_from build' -l plan-from -d 'Read build plan from file' +complete -c tan -n '__fish_seen_subcommand_from build' -l materialise -d 'Materialise plan files' +complete -c tan -n '__fish_seen_subcommand_from build' -l native -d 'Build natively' +complete -c tan -n '__fish_seen_subcommand_from build' -l manifest -d 'Show the system manifest' +complete -c tan -n '__fish_seen_subcommand_from build' -l manifest-from -d 'Read manifest from file' +complete -c tan -n '__fish_seen_subcommand_from build' -l no-auto-bootstrap -d 'Never bootstrap implicitly' +complete -c tan -n '__fish_seen_subcommand_from build' -l pristine -d 'Force-wipe build dirs before dispatch' +complete -c tan -n '__fish_seen_subcommand_from kconfig' -l core -d 'Core id to scope the menu to' +complete -c tan -n '__fish_seen_subcommand_from image flash clean renode size' -l build-root -d 'Override build root' +complete -c tan -n '__fish_seen_subcommand_from flash' -l dry-run -d 'Print planned commands only' +complete -c tan -n '__fish_seen_subcommand_from flash' -l core -d 'Flash only this core' +complete -c tan -n '__fish_seen_subcommand_from flash' -l helper -d 'Flash only this helper MCU' +complete -c tan -n '__fish_seen_subcommand_from flash' -l skip-missing-tools -d 'Skip entries with no tool on PATH' +complete -c tan -n '__fish_seen_subcommand_from run' -l flash -d 'Flash the board after building' +complete -c tan -n '__fish_seen_subcommand_from run' -l core -d 'Flash only this core' +complete -c tan -n '__fish_seen_subcommand_from clean' -l dry-run -d 'List targets without removing' +complete -c tan -n '__fish_seen_subcommand_from renode size' -l board -d 'Override SoM SKU' +complete -c tan -n '__fish_seen_subcommand_from renode' -l core -d 'Zephyr slice core id' +complete -c tan -n '__fish_seen_subcommand_from renode' -l image-bundle -d 'Pre-built artefacts dir' +complete -c tan -n '__fish_seen_subcommand_from renode' -l log -d 'Console log file' +complete -c tan -n '__fish_seen_subcommand_from renode' -l timeout -d 'Wall-clock cap in seconds' +complete -c tan -n '__fish_seen_subcommand_from renode' -l expect -d 'Stop early on this substring' +complete -c tan -n '__fish_seen_subcommand_from renode' -l sim-mode -d 'Studio hardware-simulator mode' +complete -c tan -n '__fish_seen_subcommand_from size' -l fail-over-budget -d 'Exit non-zero over budget' +""" + + +def resolve_shell(raw: str | None) -> str | None: + """Mirror Rust's `resolve_shell`: default `bash`; trim + lowercase; else + `None` (unsupported).""" + normalized = (raw if raw is not None else "bash").strip().lower() + if normalized in ("bash", "zsh", "fish"): + return normalized + return None + + +def script_for(shell: str) -> str: + """Select the embedded script for a resolved `shell` name. Mirrors Rust's + `script_for`, including its fallback: an unrecognised value (unreachable + from `completion()` below, since that already rejected it) falls back to + bash rather than raising.""" + if shell == "zsh": + return ZSH_SCRIPT + if shell == "fish": + return FISH_SCRIPT + return BASH_SCRIPT + + +def _null_project() -> Project: + """`completion` is project-agnostic: no root, no board.yaml, ever.""" + return Project(root=None, board_yaml=None) + + +def completion( + ctx: typer.Context, + shell: str = typer.Option( + None, + "--shell", + metavar="SHELL", + help="Target shell (bash, zsh, or fish). Defaults to bash.", + ), + project: str = typer.Option( + None, "--project", metavar="PATH", help="Project root (defaults to '.')." + ), + board_yaml: str = typer.Option( + None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path." + ), + sdk_root: str = typer.Option( + None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." + ), + output_format: str = typer.Option( + None, "--format", metavar="FORMAT", help="Output format: text or json." + ), + quiet: bool = typer.Option(False, "--quiet", hidden=True), + verbose: bool = typer.Option(False, "--verbose", hidden=True), + no_color: bool = typer.Option(False, "--no-color", hidden=True), + non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True), + ci: bool = typer.Option(False, "--ci", hidden=True), + target: str = typer.Option(None, "--target", hidden=True), + all_targets: bool = typer.Option(False, "--all", hidden=True), +) -> None: + """Emit a shell completion script (bash, zsh, or fish).""" + # `project`/`board_yaml`/`sdk_root` are clap `GlobalArgs` (`global = true`) + # this command never reads -- `completion` is project-agnostic on the + # oracle too (see `_null_project` above). `quiet`/`verbose`/`no_color`/ + # `non_interactive`/`ci`/`target`/`all_targets` are the rest of that same + # set: declared ONLY so the argv surface matches clap (`tan completion + # --ci` must exit 0, not a Click usage error), never read. Mirrors + # `clean_cmd.clean`'s identical block. + del project, board_yaml, sdk_root + del quiet, verbose, no_color, non_interactive, ci, target, all_targets + + # `--format` is accepted BEFORE the subcommand too (clap's `global = + # true`; verified against the oracle: `tan --format json completion + # --shell zsh` reaches this command and emits the envelope). The root + # callback (`cli.py`) records a leading value on `ctx.obj`; this option + # overrides it when repeated after the subcommand name. `is not None`, + # not a bare `or`: an explicit `--format ""` must still reach the + # validation below and exit 2, matching the oracle (measured: `tan + # completion --format ""` -> rc 2) -- `output_format or ...` would treat + # `""` as absent and silently fall back to text instead. Mirrors + # `deferred_cmd._make_stub`'s identical fix. + resolved_format = ( + output_format if output_format is not None else (ctx.obj or {}).get("format") or "text" + ) + if resolved_format not in ("text", "json"): + raise typer.BadParameter( + f"'{resolved_format}' (choose from 'text', 'json')", param_hint="--format" + ) + json_mode = resolved_format == "json" + + resolved_shell = resolve_shell(shell) + if resolved_shell is None: + if json_mode: + emit( + Envelope( + "completion", + _null_project(), + {"schemaVersion": DATA_SCHEMA_VERSION, "shell": "bash", "script": ""}, + [Issue(SHELL_UNSUPPORTED_CODE, "error", SHELL_UNSUPPORTED_MESSAGE)], + ExitCode.RUNTIME_FAILURE, + ) + ) + else: + # stderr, like every other command's text-mode error line; stdout + # stays empty on this path (matches the oracle, measured). + print(SHELL_UNSUPPORTED_TEXT_LINE, file=sys.stderr) + raise typer.Exit(int(ExitCode.RUNTIME_FAILURE)) + + script = script_for(resolved_shell) + if json_mode: + emit( + Envelope( + "completion", + _null_project(), + {"schemaVersion": DATA_SCHEMA_VERSION, "shell": resolved_shell, "script": script}, + [], + ExitCode.SUCCESS, + ) + ) + else: + # The script IS the payload (README: "tan completion --shell zsh + # emits a completion script"), and the only sane way to consume it is + # `eval "$(tan completion --shell zsh)"` / `> file` stdout capture -- + # so print it straight to stdout, not through the stderr-only + # text-line convention every other command's text mode uses. Mirrors + # `completion.rs`'s own `println!` plus its comment on why. + print(script) + raise typer.Exit(int(ExitCode.SUCCESS)) diff --git a/python/tan/commands/debug_config_cmd.py b/python/tan/commands/debug_config_cmd.py index 7b1a50b1..0e3d1693 100644 --- a/python/tan/commands/debug_config_cmd.py +++ b/python/tan/commands/debug_config_cmd.py @@ -71,6 +71,7 @@ parse_target_kind, sdk_identity_overwrites, ) +from tan.core.global_flags import accept_global_flags from tan.core.jsonc_splice import pretty_json from tan.core.run import native_sim_exe_beside from tan.core.size import resolve_variant @@ -544,6 +545,57 @@ def _resolve_user_svd(workspace_root: str, arg: str) -> str: return _workspace_relative(workspace_root, candidate) +def _resolve_gdbserver_address(arg: str) -> str: + """Validate `--gdbserver-address` (tan-cli#321). Emitted verbatim into + `miDebuggerServerAddress` -- cppdbg accepts a bare hostname, an IPv4 or + bracketed-IPv6 literal, so there is no single `host:port` shape narrow + enough to validate without rejecting a real one; the only input that can + never be a real address is an empty string, the same floor `--svd` holds + for its own path argument. + """ + if arg.strip() == "": + raise DebugConfigError("Alp: --gdbserver-address was given an empty value.") + return arg + + +def _gdbserver_address_unresolved_issue() -> Issue: + """tan-cli#321 direction 1: the yocto-userspace draft's + `miDebuggerServerAddress` is still the unresolved `:` + placeholder in what this run actually produced. Severity `info` -- this is + not a failure, it is the one field on this target class that NO build and + NO SDK-published metadata can ever resolve (it names where the board ends + up after deploy, a fact that exists only at runtime), so surfacing it + explicitly is the whole point of this issue rather than leaving F5 to fail + silently at connect. + + tan-cli#138 vs #321: unlike the other three target classes, this profile's + `preLaunchTask` carries NO restored default -- see + `tan.core.debug_launch.DEFAULT_PRE_LAUNCH_TASK`'s own doc comment for why: + alp-sdk-vscode registers no working task for yocto-userspace (the only one + that exists exits 1 by design), so naming one here would put the + "preLaunchTask terminated with exit code 1" dialog in front of every F5. + Said here, alongside the address gap, rather than as a second issue: both + point at the same manual deploy-and-start-gdbserver step, and a customer + who wants a reminder can still opt one in explicitly. + """ + return Issue( + "debug-config.gdbserver-address-unresolved", + "info", + "This yocto-userspace configuration's `miDebuggerServerAddress` is " + "still the placeholder `:` -- the host and gdbserver port " + "are a runtime property of the deployed board that no build can " + "resolve. Fill it in by hand in launch.json once you know it, or pass " + "`--gdbserver-address host:port` next time you regenerate this " + "profile. tan has no deploy mechanism of its own, so deploying the " + "binary and starting gdbserver on the target before F5 is still a " + "manual step; this profile carries no `preLaunchTask` reminder of " + "that by default (tan-cli#138 vs #321 -- the extension's only " + "registered task for this target exits 1 by design, so naming it " + "would fail before every F5). Pass `--pre-launch-task ''` to " + "add a reminder of your own.", + ) + + def _has_placeholder(value: Any) -> bool: """Whether any `<...>` placeholder survived resolution, anywhere in the draft -- including inside `configFiles`, which is an array. @@ -836,6 +888,7 @@ def _run( server_arg: str | None, core: str | None, pre_launch_task: str | None, + gdbserver_address: str | None, svd: str | None, preview: bool, project_arg: str, @@ -915,6 +968,15 @@ def _run( except DebugConfigError as err: return _internal_failure(generated_at, str(err), launch_json_path) + # `--gdbserver-address` is the ONLY producer of `resolution.gdbserver_address` + # (tan-cli#321): a runtime property of the deployed board, so nothing else + # -- not a build, not SDK-published metadata -- can ever fill it. + if gdbserver_address is not None: + try: + resolution.gdbserver_address = _resolve_gdbserver_address(gdbserver_address) + except DebugConfigError as err: + return _internal_failure(generated_at, str(err), launch_json_path) + apply_launch_resolution(draft, resolution) # alp-sdk#1026 review finding #4: which server-identity field the SDK @@ -939,10 +1001,30 @@ def _run( "no svdFile field, so it had no effect: the Cortex Peripherals view " "is a cortex-debug (MCU) feature." ) + # Same "no silent no-op" floor as `--svd` above: only a yocto-userspace + # draft carries `miDebuggerServerAddress` at all. + if gdbserver_address is not None and "miDebuggerServerAddress" not in draft: + notes.append( + f"--gdbserver-address was given, but target kind " + f"'{target_kind or ZEPHYR_MCU}' emits no miDebuggerServerAddress " + "field, so it had no effect: that field is a yocto-userspace " + "(cppdbg) feature." + ) def success( *, replaced: bool, configuration: Any, issues: list[Issue], is_preview: bool ) -> _Outcome: + # tan-cli#321: checked against `configuration` -- the value ACTUALLY + # going out (the fresh `draft` on `--preview`, the merged + # `written_configuration` on a write) -- not the pre-merge `draft` + # this closure captures from its enclosing scope. A write that merged + # over a customer's own already-hand-filled address must not re-nag + # them every run; checking the final value is what tells the two + # apart, the same distinction `_has_placeholder` exists for. + final_issues = list(issues) + if target == YOCTO_USERSPACE and isinstance(configuration, dict): + if _has_placeholder(configuration.get("miDebuggerServerAddress")): + final_issues.append(_gdbserver_address_unresolved_issue()) return _Outcome( exit_code=ExitCode.SUCCESS, data=_data( @@ -956,7 +1038,7 @@ def success( configuration=configuration, ), project=project, - issues=issues, + issues=final_issues, text=_success_text( target=target, server=server, @@ -966,7 +1048,7 @@ def success( notes=notes, configuration=configuration, quiet=quiet, - issues=issues, + issues=final_issues, ), ) @@ -1092,8 +1174,25 @@ def debug_config( "--pre-launch-task", metavar="TASK", help=( - "Emit preLaunchTask: on the generated configuration. Off by " - "default: VS Code aborts pre-launch on a task it cannot resolve." + "Emit preLaunchTask: on the generated configuration. " + "Defaults to the v0.3.1 task name for this target (tan-cli#138): " + "'alp: build active target' (zephyr-mcu), 'alp: build baremetal " + "target' (baremetal-mcu), 'alp: build native_sim target' " + "(native-host). yocto-userspace carries no default (tan-cli#321: " + "the extension's only registered task for it exits 1 by design) " + "-- pass this flag explicitly to add a reminder. Pass an empty " + "string to omit the key entirely." + ), + ), + gdbserver_address: str = typer.Option( + None, + "--gdbserver-address", + metavar="HOST:PORT", + help=( + "Fill miDebuggerServerAddress on a yocto-userspace configuration " + "(tan-cli#321). This is a runtime property of the deployed board " + "that no build can resolve; without it the field stays the " + ": placeholder and F5 fails at connect." ), ), svd: str = typer.Option( @@ -1152,6 +1251,7 @@ def debug_config( server_arg=server, core=core, pre_launch_task=pre_launch_task, + gdbserver_address=gdbserver_address, svd=svd, preview=preview, project_arg=project or ".", @@ -1190,3 +1290,12 @@ def debug_config( for line in outcome.text: stream.write(f"{line}\n") raise typer.Exit(int(outcome.exit_code)) + + +# tan-cli#261: adds the six oracle `GlobalArgs` flags this command was still +# missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--target`/ +# `--verbose`) on top of `--quiet`, already declared and read above; see +# `tan.core.global_flags`. `ctx: typer.Context` (this command's own +# `_HONOURS_ROOT_FORMAT` seam) is untouched -- appended parameters are all +# keyword-only Options, never repositioned relative to it. +debug_config = accept_global_flags(debug_config) diff --git a/python/tan/commands/deferred_cmd.py b/python/tan/commands/deferred_cmd.py index 388a1aee..234b4d4e 100644 --- a/python/tan/commands/deferred_cmd.py +++ b/python/tan/commands/deferred_cmd.py @@ -1,173 +1,41 @@ # SPDX-License-Identifier: Apache-2.0 -"""Uniform stubs for the seven `tan` verbs the Python port does not yet -implement: `scaffold`, `completion`, `diff`, `pinmux`, `inspect`, `trace`, and -`support-bundle`. Every one of them is a REAL, working command in the Rust -oracle (`crates/tan-cli/src/cli.rs`'s `Commands` enum); porting each is -deliberately deferred to v0.6.0 (tan-cli#260), and this module exists only so -a v0.4.1 script that calls one gets a clear, coded refusal instead of Typer's -unknown-command usage error. - -**Why registering the verb (rather than leaving it absent) is the fix.** A -name Typer has never heard of is a Click `UsageError`: exit 2, `cli.parse-error` -on the wire, and a message that reads exactly like a typo -- indistinguishable -from `tan bulid`. That is a strictly worse signal than the truth, which is -"this verb exists, tan knows about it, and it is not here YET". Registering it -here changes only the diagnosis; it adds no behaviour the real command would -have. A caller (or the extension) that greps for the issue code below, or the +"""The shared spelling of "tan knows this, and it is not here YET". + +**All seven verbs this module used to stub are now ported** (tan-cli#260: +`scaffold`, `completion`, `diff`, `pinmux`, `inspect`, `trace`, +`support-bundle`), so the stub factory and its `DEFERRED_VERBS` tuple are +gone. What remains is the two constants `build_cmd.py` still needs for the +deferred *flags* it declares -- `--plan`, `--target`, and friends, which are +real, working flags of the v0.4.1 oracle that this port does not implement yet +and refuses explicitly rather than as a typo. + +**Why a declared refusal beats an absent one, for a flag exactly as for a +verb.** A name Typer has never heard of is a Click `UsageError`: exit 2, +`cli.parse-error` on the wire, and a message that reads exactly like a typo -- +indistinguishable from `tan bulid`. That is a strictly worse signal than the +truth. A caller (or the extension) that greps for the issue code below, or the `tan-cli#260` URL in the message, can special-case "deferred" from "typo" -without a hardcoded verb list of its own. +without a hardcoded list of its own. **Exit code: `RUNTIME_FAILURE` (1), not `VALIDATION_FAILURE` (2), chosen deliberately.** `VALIDATION_FAILURE` is what Click's `UsageError` already returns for a truly unknown command/flag -- reusing it here would put the "known but deferred" case back at the exact same exit code as the "typo" case -this module exists to distinguish it from, silently defeating the point. Every -one of these seven verbs parses cleanly (any positional/flags are accepted, -never rejected) and is refused only once tan has recognised it -- the same -shape as `clean.sdk-root-not-found` (`clean_cmd.py`): a well-formed -invocation of a real command that cannot proceed. `RUNTIME_FAILURE` is what -that shape already uses elsewhere in this port. +this module exists to distinguish it from, silently defeating the point. -**Issue code: one shared `cli.command-deferred`, not seven per-verb codes.** -All seven stubs report literally the same fact -- "this verb is deferred to -v0.6.0" -- so a caller that wants to special-case the situation needs exactly -one code to match, not seven near-duplicates that could drift. This code is -NOT in `contract/issue-codes.json`: nothing consumes it with `===` today (no -different than `cli.parse-error`/`envelope.serialize-failed`, the two other -command-agnostic codes `tan.envelope`/`tan.cli` already emit unregistered). -`contract/` is frozen on this branch, so it cannot be edited here regardless -of status -- but the registry's own `_comment` defines `status: "reserved"` -as exactly this pre-consumer state (the spelling exists at the emission site, -but nobody matches it with `===` yet), which is what `cli.command-deferred` -already is today, not the premature case the earlier wording claimed. -FOLLOW-UP: once `contract/` is open for edits again, register -`cli.command-deferred` there as `"status": "reserved"` with -`"emittedBy": "python/tan/commands/deferred_cmd.py"` and a `"literal"` entry -(a `reserved` row needs both, per the other `reserved` rows already in the -file, and `crates/tan-cli/tests/contract.rs`'s `frozen_issue_codes` gates the -emission site actually matching them) -- not straight to `frozen`, since no -consumer binds to it yet. +**Issue code: one shared `cli.command-deferred`.** Every deferral reports +literally the same fact, so a caller that wants to special-case the situation +needs exactly one code to match, not one per site. """ from __future__ import annotations -import typer - -from tan.envelope import Envelope, Issue, Project, emit -from tan.exit_codes import ExitCode - -#: Shared by every stub below -- see the module docstring's "Issue code" -#: section for why one code, not seven. +#: Shared by every deferral -- see the module docstring's "Issue code" section. DEFERRED_ISSUE_CODE = "cli.command-deferred" -#: The tan-cli issue tracking the real Python port of every verb this module -#: stubs. Named in every stub's message, per the tan-cli#260 deferral itself. +#: The tan-cli issue tracking the deferred surface. Named in every message. DEFERRED_ISSUE_URL = "https://github.com/alplabai/tan-cli/issues/260" -#: Every verb this module stubs -- the argv surface accepts (and silently -#: discards) anything, so a caller's existing flags/positionals never turn -#: into a SEPARATE parse-error ahead of the deferral message. +#: Accept (and silently discard) any positional/flag argv, so a caller's +#: existing arguments never turn into a SEPARATE parse-error ahead of the +#: deferral message. DEFERRED_CONTEXT_SETTINGS = {"ignore_unknown_options": True, "allow_extra_args": True} - -#: The canonical list of verbs this module stubs -- the single source both -#: `tan.cli._HONOURS_ROOT_FORMAT` and `tests/commands/test_deferred_commands.py` -#: derive from, instead of each retyping the same seven names (a THIRD and -#: FOURTH copy respectively; the individual `_make_stub("...")` calls at the -#: bottom of this module are the first, unavoidable one). -DEFERRED_VERBS = ( - "scaffold", - "completion", - "diff", - "pinmux", - "inspect", - "trace", - "support-bundle", -) - - -def _deferred_message(name: str) -> str: - return ( - f"tan {name} is deferred to v0.6.0 and not available in this build " - f"(see {DEFERRED_ISSUE_URL})." - ) - - -def _run_deferred(name: str, output_format: str) -> None: - """Report `name` as deferred and exit `RUNTIME_FAILURE`, in whichever - format the caller asked for -- see the module docstring for why.""" - if output_format not in ("text", "json"): - raise typer.BadParameter( - f"'{output_format}' (choose from 'text', 'json')", param_hint="--format" - ) - message = _deferred_message(name) - if output_format == "json": - emit( - Envelope( - name, - Project(root=None, board_yaml=None), - {"message": message}, - [Issue(DEFERRED_ISSUE_CODE, "error", message)], - ExitCode.RUNTIME_FAILURE, - ) - ) - else: - # This function's own contract: text mode writes only to stderr, like - # every other command's text-mode error line. Whether stdout also ends - # up carrying a JSON envelope is decided one layer up, by `main`'s - # textual `_wants_json(argv)` scan -- e.g. `tan --format json scaffold - # --format text` resolves to text mode HERE (this branch runs) but - # `main` still sees "json" in argv and synthesizes a mislabelled - # `cli.parse-error` envelope on stdout for the nonzero exit (measured; - # pre-existing, shared with flash/size/image -- a `main`-level defect, - # not this function's to fix). - typer.echo(f"{name}: {message}", err=True) - raise typer.Exit(int(ExitCode.RUNTIME_FAILURE)) - - -def _make_stub(name: str): - """Build one `app.command()`-ready callable for verb `name`. A factory - rather than seven hand-written near-identical functions: the seven differ - only in the string `name`, and Typer reads a command's registered NAME - from the `app.command("...")` call in `cli.py`, not from this function's - `__name__` -- so nothing here needs a distinct identity beyond its - docstring (`--help` text) and closure over `name`. - """ - - def command( - ctx: typer.Context, - args: list[str] = typer.Argument(None, metavar="ARGS..."), - output_format: str = typer.Option( - None, "--format", metavar="FORMAT", help="Output format: text or json." - ), - ) -> None: - del args # accepted and ignored -- see DEFERRED_CONTEXT_SETTINGS above - # `--format` is accepted BEFORE the subcommand too (`tan --format json - # scaffold`, which the oracle's `global = true` clap flag allows and - # which `_HONOURS_ROOT_FORMAT` in cli.py lists all seven of these verbs - # under) -- the root callback records it on `ctx.obj` and this option - # overrides it when repeated after the verb name. Mirrors - # `debug_config_cmd.py:debug_config`'s `resolved_format` line. - # - # `is not None`, not a bare `or`: an explicit `--format ""` must reach - # `_run_deferred`'s validation and exit 2, matching the oracle - # (measured: `tan scaffold --format ""` -> rc 2, "a value is required - # for '--format '"). A plain `output_format or ...` treats "" - # as absent and silently falls back to "text" -- rc 1 -- which is the - # divergence this port used to have. - resolved_format = ( - output_format if output_format is not None else (ctx.obj or {}).get("format") or "text" - ) - _run_deferred(name, resolved_format) - - command.__doc__ = ( - f"Deferred to v0.6.0, not yet ported to this build ({DEFERRED_ISSUE_URL})." - ) - return command - - -scaffold = _make_stub("scaffold") -completion = _make_stub("completion") -diff = _make_stub("diff") -pinmux = _make_stub("pinmux") -inspect = _make_stub("inspect") -trace = _make_stub("trace") -support_bundle = _make_stub("support-bundle") diff --git a/python/tan/commands/diff_cmd.py b/python/tan/commands/diff_cmd.py new file mode 100644 index 00000000..dc41d024 --- /dev/null +++ b/python/tan/commands/diff_cmd.py @@ -0,0 +1,654 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan diff` -- show how `normalize_board_model` changes the parsed +board.yaml (tan-cli#260). + +Mirrors `crates/tan-cli/src/commands/diff.rs` plus the two `tan-core` helpers +it composes (`model::{parse_board_model, normalize_board_model}`, +`diff::{collect_diff_entries, prune_nulls}`). + +**Why this is NOT a generic recursive JSON differ, unlike the Rust.** The Rust +parses the WHOLE `board.yaml` into a typed `BoardModel`, normalizes it, and +diffs the two full trees with a generic recursive walk +(`tan_core::diff::collect_recursive`). `normalize_board_model` +(`crates/tan-core/src/model.rs:217-237`) only ever CLEARS four top-level +fields, and only ever to `None`/absent -- it never adds a key and never +changes one that survives: + +* schema version < 2: `libraries` if it deserialized to an EMPTY list, `iot` + if none of its four toggles is `true`, `inference` if both its fields are + empty/absent. +* schema version >= 2: `os` unconditionally (v2 moves it into `cores:`). + +Every other known field (`som`, `preset`, `cores`, `ipc`, `diagnostics`, +`populated`, `chips`, `e1m_routes`) is IDENTICAL between the parsed and +normalized model, so a full recursive diff would recurse into each, find +`before == after`, and contribute zero entries -- the same outcome this +module reaches directly, without building or comparing either side's full +tree. A `DiffEntry.kind` is therefore always `"removed"` here; `"added"`/ +`"changed"` are unreachable through `normalize_board_model` and are kept only +so the wire shape (`DiffKind`: `added`/`removed`/`changed`) stays the +contract's, not because this module can produce them today. + +**PyYAML is required.** Unlike the shallow top-level-shape scanners in +`validate_cmd`/`presets_cmd` (scalar-vs-block only), computing this diff needs +real nested values -- is `iot:` a mapping, is any of its four toggles `true`, +is `inference:`'s `backend` empty -- which a line-oriented fallback cannot +answer. tan ships no YAML dependency of its own (`typer` + `rich` only), so a +build with no PyYAML installed refuses with `diff.pyyaml-unavailable` +(`RUNTIME_FAILURE`, matching `validate_cmd`'s `spawn-not-implemented` +precedent for "this build cannot do that yet") rather than guessing. + +**YAML 1.1 vs 1.2 boolean literals.** PyYAML's default `SafeLoader` resolves +YAML 1.1's full loose bool vocabulary (`on`/`off`/`yes`/`no`/`y`/`n`, any +case) to `bool`; `serde_yaml` (YAML 1.2 core schema) resolves only the six +canonical `true`/`True`/`TRUE`/`false`/`False`/`FALSE` spellings and leaves +everything else a plain string -- measured against the oracle: +`schemaVersion: 1` + `os: on` is `changes: [{"path":"libraries",...}]` at exit +0 there (`os` is a `String` field, untouched at schema version 1), but the +stock loader hands `_parse_fields` a Python `bool` for `os` and every +`_typed_field(..., str, ...)` check refuses it as exit 2 +`diff.schema-violation` -- a live false-refusal for every YAML-1.1-only +boolean spelling in ANY string-typed field (`os`, `preset`), not just this +one example. `_load_document` therefore parses with `_Yaml12BoolLoader`, a +`SafeLoader` subclass with the YAML 1.1 bool resolver's `on`/`off`/`yes`/`no`/ +`y`/`n` patterns removed, rather than plain `yaml.safe_load`. + +**Scope of the structural checks below.** `_parse_fields` validates the +TOP-LEVEL type of every known `BoardModel` field (is `cores:` a mapping, is +`ipc:` a list, ...) because a real Rust type mismatch anywhere in the document +fails the WHOLE typed parse (`ParseError::Yaml`), and reporting success with a +wrong diff would be a worse defect than an over-eager refusal. It does NOT +validate the shape of values `diff` never reads (`cores.*.peripherals`, +`e1m_routes.*`, ...) -- those fields are never touched by +`normalize_board_model` and never enter this module's output; going a level +deeper than "top-level key has the right YAML kind" would just be more parser +tan does not need for the one question this command answers. + +`iot`'s four toggles and `inference.backend`/`inference.default_arena_kib` are +the one exception: they gate `compute_diff_entries`'s pruning decision +directly, so an unchecked wrong type there does not just under-refuse -- it +mis-classifies pruning and fabricates a diff entry the oracle never emits +(measured: `iot: {wifi: "yes"}` used to report `ok:true` with a manufactured +`{"path":"iot","kind":"removed",...}` entry; the oracle is exit 2 +`diff.schema-violation`). `iot`'s four toggles must each be `bool` or absent. +`inference.default_arena_kib` must be a non-negative integer (`u32` range) or +absent. `inference.backend` is the one `String` field checked here at all -- +and, matching the `os`/`preset` leniency above, it is checked only for the +compound shapes (`list`/`dict`) no `String` field can ever hold; any other +scalar PyYAML resolves it to (even a bare `5` or `true`) is accepted as +non-empty, exactly as the oracle's own String-field coercion treats it +(measured: `inference: {backend: 5}` is `unchanged: true` at exit 0 on the +oracle, never pruned) -- only `_inference_is_empty`'s stringification needed +fixing to stop miscounting a non-`str` truthy `backend` as blank. + +`som:`'s own shape (must be a mapping, not a bare SKU string) is checked with +the exact oracle wording via Python's own `repr()` -- which is actually the +*more* correct implementation of the two: the Rust's `python_repr` hand-mimics +Python's `repr()` from a `serde_yaml` (YAML 1.2) value and has one known gap +against real Python semantics on YAML 1.1-vs-1.2 boolean/null resolution +(`tan_core::validate` module docs); this module calls `repr()` on a value +`_Yaml12BoolLoader` (YAML 1.2's narrower bool vocabulary, the same rules +`serde_yaml` uses) actually parsed, so there is nothing left to approximate. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import typer + +from tan.commands.presets_cmd import resolve_project_paths, resolve_sdk +from tan.envelope import Envelope, Issue, Project, SdkInfo, emit +from tan.exit_codes import ExitCode + +#: `data.schemaVersion` for this command's payload -- the envelope payload's +#: own version, unrelated to `board.yaml`'s `schemaVersion:`. +DATA_SCHEMA_VERSION = "1" + +#: Serialized `Iot`/`Inference` field order (`crates/tan-core/src/model.rs`'s +#: struct declaration order) -- `preserve_order` serde_json keeps this order +#: for the wire `before` value, and it is fixed regardless of the YAML +#: source's own key order, so it is spelled out here rather than derived from +#: dict iteration. +_IOT_FIELDS = ("wifi", "mqtt", "ble", "tls") +_INFERENCE_FIELDS = ("backend", "default_arena_kib") + + +class ParseFailure(Exception): + """A `board.yaml` this offline diff cannot process. `code` is the + `diff.` issue suffix; `message` already carries the oracle's + `board.yaml is not valid[ YAML]: ...` prefix so callers pass it straight + through.""" + + def __init__(self, code: str, message: str, exit_code: ExitCode = ExitCode.VALIDATION_FAILURE): + self.code = code + self.message = message + self.exit_code = exit_code + super().__init__(message) + + +@dataclass(frozen=True) +class DiffEntry: + path: str + kind: str # "added" | "removed" | "changed" -- see module docstring + before: Any = None + after: Any = None + + def as_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {"path": self.path, "kind": self.kind} + if self.before is not None: + out["before"] = self.before + if self.after is not None: + out["after"] = self.after + return out + + +#: YAML 1.2 core schema bool literals -- `true`/`True`/`TRUE`/`false`/`False`/ +#: `FALSE` only. Everything YAML 1.1 additionally resolved to bool (`on`/ +#: `off`/`yes`/`no`/`y`/`n`, any case) is deliberately absent: those are the +#: exact patterns `_yaml_1_2_bool_loader` strips from PyYAML's own resolver, +#: so `re.compile` never sees them either -- one list, not two that could +#: drift apart. +_YAML_1_2_BOOL_PATTERN = re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$") + + +def _yaml_1_2_bool_loader(yaml_module: Any) -> type: + """A `yaml_module.SafeLoader` subclass with the YAML 1.1-only loose bool + literals removed from implicit resolution, so a scalar like `on`/`off`/ + `yes`/`no`/`y`/`n` (any case) parses as a plain STRING -- matching + `serde_yaml`'s YAML 1.2 core-schema bool tag (see the module docstring's + "YAML 1.1 vs 1.2 boolean literals" note). Takes the imported `yaml` + module rather than importing it itself, so a build with no PyYAML + installed never touches `yaml.SafeLoader` at all -- `_load_document` + only calls this after its own optional import already succeeded. + """ + + class Yaml12BoolLoader(yaml_module.SafeLoader): + pass + + # `add_implicit_resolver` only APPENDS; the stock YAML-1.1 bool resolver + # would still match first and win. Copy the resolver table with every + # existing bool entry stripped, then append the narrower one, so this + # loader's `tag:yaml.org,2002:bool` entries are exactly the six literals + # above -- nothing from the base `SafeLoader` (used unmodified everywhere + # else in tan) is touched. + Yaml12BoolLoader.yaml_implicit_resolvers = { + first: [pair for pair in resolvers if pair[0] != "tag:yaml.org,2002:bool"] + for first, resolvers in yaml_module.SafeLoader.yaml_implicit_resolvers.items() + } + Yaml12BoolLoader.add_implicit_resolver( + "tag:yaml.org,2002:bool", _YAML_1_2_BOOL_PATTERN, list("tTfF") + ) + return Yaml12BoolLoader + + +def _load_document(text: str) -> Any: + """The raw YAML document, or a `ParseFailure` matching `ParseError`'s two + reachable variants on this path (`Yaml`, and the `som:`-shape pre-check). + `EmptyDocument`/`NotAMapping` are NOT reachable here -- those are + `validate_cmd`'s `reject_non_mapping_document`, which `diff` never calls; + a null or bare-scalar document degrades to the default (empty) model here, + matching `parse_board_model`'s own TS-parity leniency. + """ + try: + import yaml # noqa: PLC0415 (optional at runtime, by design) + except ImportError as err: + raise ParseFailure( + "pyyaml-unavailable", + "this build of tan has no YAML support installed, so `tan diff` cannot " + "compute a normalization diff.", + ExitCode.RUNTIME_FAILURE, + ) from err + try: + return yaml.load(text, Loader=_yaml_1_2_bool_loader(yaml)) + except Exception as err: # noqa: BLE001 -- yaml.YAMLError and anything a loader raises + raise ParseFailure("schema-violation", f"board.yaml is not valid YAML: {err}") from err + + +def _yaml_kind(value: Any) -> str: + """A short YAML-ish type name for an error message -- not a claim of + matching serde's exact wording (see the module docstring's scope note).""" + if value is None: + return "null" + if isinstance(value, bool): + return "a boolean" + if isinstance(value, (int, float)): + return "a number" + if isinstance(value, str): + return "a string" + if isinstance(value, list): + return "a sequence" + if isinstance(value, dict): + return "a mapping" + return type(value).__name__ + + +def _typed_field(doc: dict, key: str, expected: type, label: str) -> Any: + """`doc[key]` if absent or already `expected`-shaped, else a + `ParseFailure` mirroring the whole-document failure a struct-typed + `serde_yaml` deserialize would raise for the same mismatch.""" + value = doc.get(key) + if value is None or isinstance(value, expected): + return value + raise ParseFailure( + "schema-violation", + f"board.yaml is not valid YAML: {key}: expected {label}, got {_yaml_kind(value)}", + ) + + +#: `u32::MAX` -- the upper bound `inference.default_arena_kib` (`u32` in the +#: Rust model) accepts. Measured against the oracle: `4294967295` is exit 0, +#: `4294967296` is exit 2 `inference.default_arena_kib: ... expected u32`. +_U32_MAX = 0xFFFFFFFF + + +def _typed_nested(mapping: dict, key: str, path: str, expected: type, label: str) -> Any: + """Like `_typed_field`, but for a key nested one level under an + already-`dict`-shaped `mapping` -- `path` is the dotted diagnostic path + (`"iot.wifi"`) rather than a bare top-level key.""" + value = mapping.get(key) + if value is None or isinstance(value, expected): + return value + raise ParseFailure( + "schema-violation", + f"board.yaml is not valid YAML: {path}: expected {label}, got {_yaml_kind(value)}", + ) + + +def _check_iot_field_types(iot: dict | None) -> None: + """Each of `iot`'s four toggles must be `bool` or absent -- checked + before `compute_diff_entries` ever asks whether the group is prunable + (see the module docstring's "scope of the structural checks" note).""" + if iot is None: + return + for field in _IOT_FIELDS: + _typed_nested(iot, field, f"iot.{field}", bool, "a boolean") + + +def _check_inference_field_types(inference: dict | None) -> None: + """`inference.default_arena_kib` must be a non-negative `u32`-range + integer or absent. `inference.backend` is a `String` field: matching the + `os`/`preset` leniency documented at the top of the module, only the + compound shapes (`list`/`dict`) no `String` field can ever hold are + rejected here -- every other scalar PyYAML resolves it to is accepted, + same as the oracle's own coercion (`_inference_is_empty` is what needed + fixing to stop mis-treating a non-`str` truthy `backend` as blank).""" + if inference is None: + return + backend = inference.get("backend") + if isinstance(backend, (list, dict)): + raise ParseFailure( + "schema-violation", + f"board.yaml is not valid YAML: inference.backend: expected a string, " + f"got {_yaml_kind(backend)}", + ) + arena = inference.get("default_arena_kib") + if arena is not None and ( + isinstance(arena, bool) or not isinstance(arena, int) or not 0 <= arena <= _U32_MAX + ): + raise ParseFailure( + "schema-violation", + "board.yaml is not valid YAML: inference.default_arena_kib: expected a " + f"non-negative 32-bit integer, got {_yaml_kind(arena)}", + ) + + +def _parse_fields(doc: Any) -> tuple[int, str | None, list | None, dict | None, dict | None]: + """`(effective_schema_version, os, libraries, iot, inference)` -- the only + values `normalize_board_model` can ever act on. Raises `ParseFailure` for + every document shape that would fail the Rust's typed parse; see the + module docstring for exactly how far the type checking goes. + """ + if doc is None: + doc = {} + if not isinstance(doc, dict): + raise ParseFailure( + "schema-violation", + f"board.yaml is not valid YAML: invalid type: {_yaml_kind(doc)}, expected a mapping", + ) + + som = doc.get("som") + if som is not None and not isinstance(som, dict): + raise ParseFailure( + "schema-violation", + "board.yaml is not valid: `som:` must be a mapping carrying a `sku:` key, but " + f"a scalar was given ({som!r}). Write it as:\n som:\n sku: ", + ) + + schema_version = doc.get("schemaVersion") + schema_version_ok = isinstance(schema_version, int) and not isinstance(schema_version, bool) + if schema_version is not None and (not schema_version_ok or schema_version < 0): + raise ParseFailure( + "schema-violation", + f"board.yaml is not valid YAML: schemaVersion: expected a non-negative integer, " + f"got {_yaml_kind(schema_version)}", + ) + effective_version = schema_version if schema_version is not None else 1 + + os_value = _typed_field(doc, "os", str, "a string") + libraries = _typed_field(doc, "libraries", list, "a sequence") + iot = _typed_field(doc, "iot", dict, "a mapping") + inference = _typed_field(doc, "inference", dict, "a mapping") + _check_iot_field_types(iot) + _check_inference_field_types(inference) + + # Fields `diff` never reads (never touched by normalize_board_model, so + # never contribute a diff entry either way) -- top-level shape checked + # only, per the module docstring's scope note. + _typed_field(doc, "preset", str, "a string") + _typed_field(doc, "cores", dict, "a mapping") + _typed_field(doc, "ipc", list, "a sequence") + _typed_field(doc, "diagnostics", dict, "a mapping") + _typed_field(doc, "populated", dict, "a mapping") + _typed_field(doc, "chips", list, "a sequence") + _typed_field(doc, "e1m_routes", dict, "a mapping") + + return effective_version, os_value, libraries, iot, inference + + +def _iot_any_enabled(iot: dict) -> bool: + return any(iot.get(k) is True for k in _IOT_FIELDS) + + +def _iot_pruned(iot: dict) -> dict: + return {k: iot[k] for k in _IOT_FIELDS if iot.get(k) is not None} + + +def _inference_is_empty(inference: dict) -> bool: + """`backend` is empty only when absent or an explicit empty string -- + any OTHER present scalar (`_check_inference_field_types` has already + rejected the compound shapes) counts as non-empty regardless of its YAML + type, matching the oracle's own `String`-field coercion (measured: + `inference: {backend: 5}` is `unchanged: true`, never pruned). Naively + defaulting a non-`str` `backend` to `""` here -- as this used to -- is + exactly the bug: it silently treated a present, non-empty `backend` as + blank and let `compute_diff_entries` fabricate a diff entry the oracle + never emits.""" + backend = inference.get("backend") + if backend is not None and backend != "": + return False + return inference.get("default_arena_kib") is None + + +def _inference_pruned(inference: dict) -> dict: + return {k: inference[k] for k in _INFERENCE_FIELDS if inference.get(k) is not None} + + +def compute_diff_entries( + effective_version: int, + os_value: str | None, + libraries: list | None, + iot: dict | None, + inference: dict | None, +) -> list[DiffEntry]: + """The `normalize_board_model` effect as `DiffEntry` list, sorted by path + (matching `collect_diff_entries`'s own `sort_by(path)` -- alphabetical + among `inference`/`iot`/`libraries`/`os` needs no explicit sort since at + most one of `{inference, iot, libraries}` XOR `{os}` group is ever + populated, but sorting keeps the guarantee explicit rather than accidental). + """ + entries: list[DiffEntry] = [] + if effective_version < 2: + if libraries is not None and len(libraries) == 0: + entries.append(DiffEntry("libraries", "removed", before=[])) + if iot is not None and not _iot_any_enabled(iot): + entries.append(DiffEntry("iot", "removed", before=_iot_pruned(iot))) + if inference is not None and _inference_is_empty(inference): + entries.append(DiffEntry("inference", "removed", before=_inference_pruned(inference))) + else: + if os_value is not None: + entries.append(DiffEntry("os", "removed", before=os_value)) + entries.sort(key=lambda e: e.path) + return entries + + +_KIND_LABEL = {"added": "ADDED", "removed": "REMOVED", "changed": "CHANGED"} + + +def _format_value(value: Any) -> str: + """`` for `None`, JSON-quoted for a string, compact JSON + otherwise, truncated to 117 chars + `...` past 120 -- verbatim from + `diff.rs`'s `format_value`. `ensure_ascii=False`: `serde_json::to_string` + emits raw UTF-8, never a `\\uXXXX` escape, for a non-ASCII board.yaml + string.""" + if value is None: + return "" + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False) + raw = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + if len(raw) > 120: + return raw[:117] + "..." + return raw + + +def _render_text(entries: list[DiffEntry], board_path: str, quiet: bool) -> list[str]: + if not entries: + return ["diff: no effective-config differences detected."] + lines = [f"diff: {len(entries)} differences in {board_path}"] + if not quiet: + for entry in entries: + lines.append( + f"{_KIND_LABEL[entry.kind]} {entry.path}: " + f"{_format_value(entry.before)} -> {_format_value(entry.after)}" + ) + return lines + + +def _data( + board_path: str, entries: list[DiffEntry], *, unchanged: bool | None = None +) -> dict[str, Any]: + """`unchanged` defaults to `len(entries) == 0` for the success path, but a + FAILURE envelope's `DiffData` hardcodes `unchanged: false` regardless of + its (always-empty) `changes` list -- verbatim from `diff.rs`'s `failure()` + -- so `_emit_failure` passes it explicitly rather than letting an empty + `changes: []` compute `unchanged: true` for a run that never got far + enough to answer that question.""" + return { + "schemaVersion": DATA_SCHEMA_VERSION, + "boardYamlPath": board_path, + "unchanged": (len(entries) == 0) if unchanged is None else unchanged, + "changeCount": len(entries), + "changes": [e.as_dict() for e in entries], + } + + +def _emit_failure( + *, + json_mode: bool, + root: str, + board_path: str, + code: str, + message: str, + exit_code: ExitCode, + text_lines: list[str], + sdk: SdkInfo | None = None, +) -> None: + """Mirrors `diff.rs`'s `failure(...)`: the JSON issue message and the + text-mode lines are independent strings, not one derived from the other + (`board-yaml-missing`'s text line reads differently from its issue + message) -- callers supply `text_lines` verbatim, matching the Rust + call sites' own hand-written `vec![...]`. Unlike the success path's + `_render_text`, these lines are NOT filtered by `--quiet` -- measured + against the oracle: `diff --quiet` on every failure prints the identical + lines a plain `diff` does. + + `sdk` is the resolved `--sdk-root` block, carried through to the failure + envelope exactly as the success envelope carries it -- measured against + the oracle: `diff --sdk-root ` against a missing board.yaml still + reports `sdk.root`/`sdk.sourceTier` on the exit-2 envelope; dropping it + on the failure path (as this used to) is a real divergence, not just an + asymmetry with the success path. + """ + if json_mode: + emit( + Envelope( + "diff", + Project.resolved(root, board_path), + _data(board_path, [], unchanged=False), + [Issue(f"diff.{code}", "error", message)], + exit_code, + sdk=sdk, + ) + ) + else: + stream = typer.get_text_stream("stderr") + for line in text_lines: + stream.write(f"{line}\n") + raise typer.Exit(int(exit_code)) + + +def diff( + ctx: typer.Context, + project: str = typer.Option( + None, "--project", metavar="PATH", help="Project root (defaults to current directory)." + ), + board_yaml: str = typer.Option( + None, + "--board-yaml", + metavar="PATH", + help="Explicit board.yaml path (overrides project resolution).", + ), + sdk_root: str = typer.Option( + None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." + ), + target: str = typer.Option( # accepted, not read + None, + "--target", + metavar="EMIT", + help="Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf).", + ), + all_targets: bool = typer.Option( # accepted, not read + False, "--all", help="Run command against all relevant targets." + ), + output_format: str = typer.Option( + None, "--format", metavar="FORMAT", help="Output format: text or json." + ), + verbose: bool = typer.Option( # accepted, not read + False, "--verbose", help="Emit additional diagnostic detail." + ), + quiet: bool = typer.Option( + False, "--quiet", help="Suppress non-essential output (omits the per-change lines)." + ), + no_color: bool = typer.Option( # accepted, not read; diff emits no ANSI color + False, "--no-color", help="Disable ANSI color in text output." + ), + non_interactive: bool = typer.Option( # accepted, not read; diff never prompts + False, "--non-interactive", help="Never prompt." + ), + ci: bool = typer.Option( # accepted, not read + False, "--ci", help="CI mode: implies non-interactive and disables color." + ), +) -> None: + """Show how board.yaml normalization changes the effective config. + + `--target`/`--all`/`--verbose`/`--no-color`/`--non-interactive`/`--ci` are + declared, not consumed: `diff` reads only the project's own board.yaml + plus, now, `--sdk-root` -- solely to echo the resolved SDK in the + envelope's `sdk` block, matching the oracle (measured: `diff --sdk-root + ` reports `sdk.root`/`sdk.sourceTier` on both the success AND the + board-yaml-missing failure envelope; `diff` still never READS anything + from the checkout). The oracle's clap `GlobalArgs` are `global = true`, + so every verb accepts all of them and a caller passing one through + unconditionally must not get a parse error. + """ + del target, all_targets, verbose, no_color, non_interactive, ci + resolved_format = ( + output_format if output_format is not None else (ctx.obj or {}).get("format") or "text" + ) + if resolved_format not in ("text", "json"): + raise typer.BadParameter( + f"'{resolved_format}' (choose from 'text', 'json')", param_hint="--format" + ) + json_mode = resolved_format == "json" + + root, board_path = resolve_project_paths(project, board_yaml) + sdk = resolve_sdk(sdk_root, root) + sdk_info = SdkInfo(sdk[0], sdk[1]) if sdk is not None else None + board_file = Path(board_path) + + if not board_file.exists(): + _emit_failure( + json_mode=json_mode, + root=root, + board_path=board_path, + code="board-yaml-missing", + message="board.yaml path could not be resolved or the file does not exist.", + exit_code=ExitCode.VALIDATION_FAILURE, + text_lines=["diff: board.yaml path is unresolved or missing."], + sdk=sdk_info, + ) + return + + try: + text = board_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as err: + _emit_failure( + json_mode=json_mode, + root=root, + board_path=board_path, + code="internal-failure", + message=f"could not read board.yaml: {err}", + exit_code=ExitCode.INTERNAL_FAILURE, + text_lines=["diff: internal failure", str(err)], + sdk=sdk_info, + ) + return + + try: + doc = _load_document(text) + effective_version, os_value, libraries, iot, inference = _parse_fields(doc) + except ParseFailure as failure: + header = ( + "diff: internal failure" + if failure.exit_code == ExitCode.INTERNAL_FAILURE + else "diff: validation failure" + if failure.exit_code == ExitCode.VALIDATION_FAILURE + else "diff: runtime failure" + ) + _emit_failure( + json_mode=json_mode, + root=root, + board_path=board_path, + code=failure.code, + message=failure.message, + exit_code=failure.exit_code, + text_lines=[header, failure.message], + sdk=sdk_info, + ) + return + except Exception as err: # noqa: BLE001 -- the envelope IS the error contract + message = f"diff failed unexpectedly: {err.__class__.__name__}: {err}" + _emit_failure( + json_mode=json_mode, + root=root, + board_path=board_path, + code="internal-failure", + message=message, + exit_code=ExitCode.INTERNAL_FAILURE, + text_lines=["diff: internal failure", message], + sdk=sdk_info, + ) + return + + entries = compute_diff_entries(effective_version, os_value, libraries, iot, inference) + + if json_mode: + emit( + Envelope( + "diff", + Project.resolved(root, board_path), + _data(board_path, entries), + [], + ExitCode.SUCCESS, + sdk=sdk_info, + ) + ) + else: + stream = typer.get_text_stream("stderr") + for line in _render_text(entries, board_path, quiet): + stream.write(f"{line}\n") + raise typer.Exit(int(ExitCode.SUCCESS)) diff --git a/python/tan/commands/doctor_cmd.py b/python/tan/commands/doctor_cmd.py index 2967b7f7..790bba0d 100644 --- a/python/tan/commands/doctor_cmd.py +++ b/python/tan/commands/doctor_cmd.py @@ -102,6 +102,7 @@ import os import platform import re +import shlex import subprocess import sys from dataclasses import dataclass @@ -110,7 +111,15 @@ import typer from tan.commands.build_cmd import _abs_posix, discover_sdk_root, resolve_sdk_root_ladder -from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS, parse_sdk_version_yaml, project_pin_issue +from tan.commands.sdk_cmd import ( + NO_SDK_NEXT_STEPS, + _has_loader_script, + _home_alp_dir, + _pointer_target, + global_default_pointer_fix_hint, + parse_sdk_version_yaml, + project_pin_issue, +) from tan.core.bootstrap import ( MissingPrerequisite, PrereqFailure, @@ -121,6 +130,8 @@ posix_venv_unusable, reported_missing, ) +from tan.core.consent import can_prompt +from tan.core.global_flags import accept_global_flags from tan.core.timestamp import generated_at_iso from tan.core.venv import find_workspace_venv, west_program, west_workspace_dir from tan.envelope import Envelope, Issue, Project, SdkInfo, emit @@ -1399,11 +1410,37 @@ def home_path_check(home: str | None) -> Check: # --------------------------------------------------------------------------- +def _broken_global_default() -> str | None: + """The raw `sdkPath` `~/.alp/sdk-default` names, ONLY when that pointer + file exists but its target is NOT a valid alp-sdk checkout (tan-cli#344). + `None` when the pointer is absent, unreadable/malformed, or DOES resolve + -- every one of those is indistinguishable from "nothing configured" and + stays that way; this exists to name the one case that is not. + + Reads the exact file `sdk_cmd.resolve_sdk_tiered` already reads + (`_pointer_target(_home_alp_dir() / "sdk-default")` + `_has_loader_script`) + the SAME way, purely for this one extra fact -- it changes no resolution + outcome (`resolve_sdk_root_ladder`/`resolve_sdk_tiered` are untouched by + this function; it is called separately, only to feed `sdk_check`'s + report). `resolve_sdk_tiered` itself already tracks an analogous broken + POINTER for the project-pin tier (`ActiveSdk.broken_project_pin`) and + surfaces it via `project_pin_issue` regardless of which lower tier + answers -- this is the same idea one tier up, for the one tier that had + no such memory at all: a dangling global default fell through silently, + with nothing left to report it had ever existed. + """ + target = _pointer_target(_home_alp_dir() / "sdk-default") + if target is None or _has_loader_script(Path(target)): + return None + return target + + def sdk_check( sdk_root: str | None, project_scope: str | None, tier: str | None = None, unselected_candidate: str | None = None, + broken_global_default: str | None = None, ) -> Check: """`sdk` -- is an alp-sdk checkout resolved at all? Mirrors `tan_core::preflight::build_preflight_checks`'s `sdk` check. @@ -1436,6 +1473,23 @@ def sdk_check( discovery, or nothing else resolves there) -- named explicitly, with how to select it, so a plausible checkout sitting right there does not read as unconsidered. + + `broken_global_default` (tan-cli#344, `_broken_global_default` above) is + the raw `sdkPath` a machine-global `~/.alp/sdk-default` pointer held when + that file exists but its target is no longer a valid checkout -- only + meaningful in the `sdk_root is None` branch (a global default that DID + resolve never reaches this function with `sdk_root is None` at all). + Before this, "I have nothing configured" and "what I configured is + broken and tan silently fell through past it" printed the identical + sentence: `NO_SDK_NEXT_STEPS`, which tells the user to clone a checkout + and pass `--sdk-root`, with no hint the thing they already configured is + dangling. Falling through stays correct (unchanged here) and exit 4 + stays correct (unchanged here) -- only which sentence explains it + changes. `bootstrap_cmd`'s own broken-pointer messages + (`global_default_pointer_fix_hint`) are the shape this matches: name the + pointer file directly, never `tan sdk switch`, which refuses outright in + this build (tan-cli#305) -- recommending it here would be the exact + dead end #305 already fixed for the project-pin case. """ if sdk_root is not None: detail = f"alp-sdk at {sdk_root}" @@ -1449,6 +1503,18 @@ def sdk_check( detail += ")" return Check("sdk", "pass", detail) scope_note = f" for --project {project_scope}" if project_scope is not None else "" + if broken_global_default is not None: + pointer = str(_home_alp_dir() / "sdk-default") + return Check( + "sdk", + "fail", + f"no SDK selected{scope_note} -- the machine-global default " + f'({pointer}) names "{broken_global_default}", which is not a ' + f"valid alp-sdk checkout, so tan fell through past it and found " + f"nothing else either.", + f"{global_default_pointer_fix_hint(pointer)}, or pass " + f"--sdk-root directly.", + ) return Check( "sdk", "fail", @@ -2109,6 +2175,324 @@ def resolve_manifest_python_floor(sdk_root: str | None) -> tuple[tuple[int, int] return _manifest_floor_from_facts(loaded.facts), loaded.source +#: Generous on purpose: a real install can pull a package over the network, +#: unlike every OTHER timeout in this file (`PROBE_TIMEOUT_S`), which only +#: ever waits on a local `--version` banner. `ponytail`: one fixed ceiling, +#: no live progress reporting -- raise it, or stream output, if a real +#: install exceeds it before this is revisited. +FIX_INSTALL_TIMEOUT_S = 300 + + +def fix_needs_sudo_check(tool: str, command: str) -> Check: + """`doctor.fix-needs-sudo` -- ADR 0021's Tier-B refusal (tan-cli#91, + MAINTAINER DECISION): tan never spawns `sudo` on the customer's behalf. + + Under `--format json` this process's stdio is captured end to end, so a + `sudo` password prompt has nowhere to go -- it would hang forever rather + than fail loudly, which is a worse outcome than refusing up front. REFUSE + AND PRINT: name the exact command, verbatim, so it can be pasted into a + real terminal, and stop there. `run_fix` below is the only caller, and + only reaches this branch for a command whose first word IS literally + `sudo` -- the manifest's own POSIX `prerequisites.install` commands are + the one place that word appears in this codebase at all; Windows + (`winget`, user-scope) and macOS (`brew`) never need it. + """ + return Check( + f"fix:{tool}", + "warn", + f'`--fix` will not run `{command}` for {tool}: it needs elevation ' + f'("sudo"), and tan never spawns sudo itself. Run it yourself, then ' + f"re-run `tan doctor`.", + command, + code="doctor.fix-needs-sudo", + ) + + +def fix_installed_check(tool: str, command: str) -> Check: + """`doctor.fix-installed` -- `--fix` ran a manifest install command that + needed no elevation (ADR 0021 Tier A), and the child process exited 0. + + Deliberately NOT a claim that `{tool}` is now on PATH: this process + already read its own PATH at start-up (tan-cli#91), so an install that + lands after that moment is invisible to it -- there is no same-process + re-check to perform, honestly or otherwise. "Installed; reopen your + shell" is the whole truth this check can tell; `hostPrerequisites` + above still reports `{tool}` missing in THIS report, which is correct + for THIS report. + """ + return Check( + f"fix:{tool}", + "warn", + f"`--fix` ran `{command}` for {tool}. tan cannot see a PATH change " + f"made after it started -- open a new shell, then re-run `tan " + f"doctor` there to confirm.", + code="doctor.fix-installed", + ) + + +def fix_spawn_failed_check(tool: str, command: str, err: Exception) -> Check: + """`doctor.fix-spawn-failed` -- `--fix` resolved `{tool}`'s install + command on PATH (`on_path` already succeeded) but starting it raised + (`OSError`/`ValueError`/`subprocess.SubprocessError` other than a + timeout). Distinct from silence: without this, a customer watching + `--fix` do nothing cannot tell "the OS refused to start it" from "tan + never tried".""" + return Check( + f"fix:{tool}", + "warn", + f"`--fix` could not start `{command}` for {tool}: {err}. Run it " + f"yourself, then re-run `tan doctor`.", + command, + code="doctor.fix-spawn-failed", + ) + + +def fix_failed_check(tool: str, command: str, returncode: int) -> Check: + """`doctor.fix-failed` -- `--fix` ran `{tool}`'s install command and the + child exited non-zero. `hostPrerequisites` above still reports `{tool}` + missing in THIS report (same no-same-process-recheck honesty as + `fix_installed_check`) -- this Check is the only place a customer learns + the install itself failed, rather than merely "still missing".""" + return Check( + f"fix:{tool}", + "warn", + f"`--fix` ran `{command}` for {tool}; it exited {returncode}. Run it " + f"yourself to see the full output, then re-run `tan doctor`.", + command, + code="doctor.fix-failed", + ) + + +def fix_timed_out_check(tool: str, command: str) -> Check: + """`doctor.fix-timed-out` -- `{tool}`'s install command did not finish + inside `FIX_INSTALL_TIMEOUT_S` (300s) and was killed. Without this, a + hang here reads as up to 20 minutes of silent terminal: text-mode output + only prints after the WHOLE report completes.""" + return Check( + f"fix:{tool}", + "warn", + f"`--fix` killed `{command}` for {tool} after {FIX_INSTALL_TIMEOUT_S}s " + f"with no result. Run it yourself, then re-run `tan doctor`.", + command, + code="doctor.fix-timed-out", + ) + + +#: Per-installer remedy for `fix_installer_not_found_check`, keyed by the first +#: word of the manifest's install command. Only two entries because only two +#: are reachable: `metadata/bootstrap.json`'s `prerequisites.install` produces +#: `brew` on macOS and `winget` on Windows, and Linux's `sudo apt-get ...` is +#: refused by `fix_needs_sudo_check` long before anything tries to resolve it +#: (see `_fallback_install_commands` in `tan.core.bootstrap`, which is +#: byte-pinned to the manifest). Anything else gets the generic remedy below +#: -- a manifest is free to name a package manager this table has never heard +#: of, and "not found" with no advice is the exact silence tan-cli#360 is +#: about. +INSTALLER_REMEDIES = { + "brew": ( + "Homebrew is not installed -- install it from https://brew.sh, then " + "re-run `tan doctor --fix`." + ), + "winget": ( + 'winget comes from the App Installer package -- install "App ' + 'Installer" from the Microsoft Store (Windows 10 1809 or newer), open ' + "a new shell so PATH picks it up, then re-run `tan doctor --fix`." + ), +} + + +def fix_installer_not_found_check(installer: str, tools: list[str]) -> Check: + """`doctor.fix-installer-not-found` -- tan-cli#360: `--fix` could not + resolve the program the manifest's install command starts with, so it ran + NO repair for the tools that command covers. + + This used to be a bare `continue` in `run_fix` -- the one outcome that + emitted no Check at all, in a function whose whole stated invariant is + that every entry is either run or refused and every outcome is reported. + It is also the single most likely outcome on the hosts `--fix` exists + for: the manifest installs with `brew` on macOS and `winget` on Windows, + so a fresh Mac without Homebrew, or a Windows image with no usable + winget, reported tools missing, ACCEPTED `--fix`, and then printed + exactly the same report back with no `fix:*` line anywhere. The + least-equipped host got the least diagnostic behaviour, and could not + tell "nothing needed fixing" from "tan never found the installer to try + with". + + ONE Check per installer, not per tool: a fresh Mac is missing all six + manifest prerequisites at once and every one of them says `brew`, so + per-tool would print the same "install Homebrew" paragraph six times over + -- six restatements of one fact, in a report a customer is reading to + find out what is actually wrong. `tools` therefore carries every tool + this one absence blocked, and `run_fix` groups them. + + Named `fix:{installer}` rather than `fix:{tool}` for the same reason -- + the subject of this verdict is the installer, and one name per absent + installer is what keeps the grouping legible in the text report. No + `fix` field: the per-tool install commands are not runnable until the + installer exists, and `hostPrerequisites` already carries each one + verbatim (`data.missingPrerequisites[].command`), so repeating a + command that cannot run would be worse than pointing at it. + """ + named = ", ".join(tools) + many = len(tools) > 1 + remedy = INSTALLER_REMEDIES.get(installer) or ( + f"Install `{installer}`, then re-run `tan doctor --fix`." + ) + return Check( + f"fix:{installer}", + "warn", + f"`--fix` ran no repair for {named}: the manifest installs " + f"{'them' if many else 'it'} with `{installer}`, which is not on " + f"PATH. {remedy} Or install {'those tools' if many else named} with a " + f"package manager this host already has -- `hostPrerequisites` above " + f"names {'each' if many else 'its'} exact install command.", + code="doctor.fix-installer-not-found", + ) + + +def run_fix(missing: list[dict[str, str | None]]) -> list[Check]: + """`--fix`'s ADR 0021 executor (tan-cli#91): for each tool + `hostPrerequisites` already reported missing, either run its manifest + install command (no elevation needed -- Tier A) or refuse and name it + (needs `sudo` -- Tier B), never both, never neither. `missing` is that + check's OWN structured field (`Check.missing`, `{tool, command}` pairs) + -- never a second, independently recomputed tool/command list, so this + can only ever act on exactly what the report already told the customer + was wrong. + + A tool with `command=None` (the manifest names no install command for + it) is skipped outright: nothing to run, nothing to refuse, and the + existing `hostPrerequisites` Fail already carries the honest "install it + yourself" advice for that case. + + Every outcome becomes a `Check` -- `fix_needs_sudo_check`/ + `fix_installed_check` on the two "acted, and it's fine" paths, (as of + the tan-cli#91 follow-up below) `fix_spawn_failed_check`/`fix_failed_check`/ + `fix_timed_out_check` on the three "acted, and it's NOT fine" paths, and + (tan-cli#360) `fix_installer_not_found_check` on the "could not act at + all" one -- never a bare side effect. A customer who typed `--fix` and + got the SAME report back used to have no way to tell "nothing needed + fixing" from "tan tried and silently gave up": a spawn error, a non-zero + exit, a `FIX_INSTALL_TIMEOUT_S` (300s) timeout, or an install command + whose own program is not on PATH each used to `continue` with no trace at + all, and text-mode output only prints after the WHOLE report completes -- + up to 20 minutes of silent terminal across four tools with nothing to + show for it. `hostPrerequisites`'s own Fail still names the tool and its + command either way; these Checks add the ONE fact it structurally cannot + carry -- what `--fix` itself did about it. + + Only ever called from `doctor()`'s `--fix` branch, itself gated on + `can_prompt` (`tan.core.consent`) -- the one place in this module that + mutates the host rather than merely observing it, so it is confined + exactly there, never folded into `_collect` (pure probes, see the module + docstring). + """ + results: list[Check] = [] + # installer -> every tool whose install command starts with it and that + # therefore went unrepaired (tan-cli#360). Insertion-ordered, so the + # grouped Checks appended below come out in `missing` order rather than + # an arbitrary one. + unresolved: dict[str, list[str]] = {} + for entry in missing: + tool = entry.get("tool") + command = entry.get("command") + if not tool or not command: + continue + if command.strip().startswith("sudo "): + results.append(fix_needs_sudo_check(tool, command)) + continue + argv = shlex.split(command) + if not argv: + # The one deliberately silent skip left (tan-cli#360 reviewed the + # rest): an all-whitespace install command names no program to + # report as absent, so there is nothing this could say beyond what + # `hostPrerequisites`'s own Fail already says about the tool. A + # manifest defect, not a host one. + continue + # `on_path`, never bare `subprocess.run([name, ...])`: the same + # PATH-only, no-cwd-insertion resolver every other spawn in this + # module uses (see `on_path`'s own docstring) -- a project-local + # binary happening to share the tool's name must not be what `--fix` + # runs with elevated-sounding trust. + resolved_exe = on_path(argv[0]) + if resolved_exe is None: + # tan-cli#360: collected, not silently dropped. Grouped by + # installer because one absent `brew` blocks every macOS + # prerequisite at once -- see `fix_installer_not_found_check`. + unresolved.setdefault(argv[0], []).append(tool) + continue + argv[0] = resolved_exe + try: + result = subprocess.run( + argv, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + stdin=subprocess.DEVNULL, + timeout=FIX_INSTALL_TIMEOUT_S, + check=False, + ) + except subprocess.TimeoutExpired: + results.append(fix_timed_out_check(tool, command)) + continue + except (OSError, ValueError, subprocess.SubprocessError) as err: + results.append(fix_spawn_failed_check(tool, command, err)) + continue + if result.returncode == 0: + results.append(fix_installed_check(tool, command)) + else: + results.append(fix_failed_check(tool, command, result.returncode)) + return [ + *results, + *(fix_installer_not_found_check(i, t) for i, t in unresolved.items()), + ] + + +def fix_suppressed_issue(*, non_interactive: bool, ci: bool, json_mode: bool) -> Issue: + """`doctor.fix-suppressed` -- tan-cli#91 P1, measured against the oracle: + `tan doctor --fix --format json` on an unhealthy host used to be a + byte-for-byte silent no-op vs. plain `tan doctor` -- no issue, no note, + exit code unchanged -- indistinguishable from a `--fix` that genuinely + found nothing to do. The oracle's own equivalent refuses outright + (`cli.parse-error`, exit 2); this port instead reports HONESTLY: `--fix` + was requested, the `can_prompt` consent gate (`tan.core.consent`) refused + it, and here is which of its conditions actually tripped -- not just that + nothing happened. + + Only ever called from `doctor()`, and only when `fix` is set and + `can_prompt` returned `False` for these same three flags -- never the + other way around, so this can only ever explain a REAL suppression. + + The `isatty()` pair is read ONLY when `not json_mode`, mirroring + `can_prompt`'s own short-circuit order (`... and not json_mode and + sys.stdin.isatty() and sys.stderr.isatty()`) rather than a coincidence: + under `--format json`, `tan.cli.main` tees `sys.stderr` through + `_TeeStderr`, which has no `isatty()` at all -- reading it unconditionally + here crashes this exact suppressed-fix report with + `AttributeError: '_TeeStderr' object has no attribute 'isatty'` (measured + against a real `tan doctor --fix --format json --ci` run). `json_mode` + is already a complete, accurate reason on its own; there is nothing the + tty state could add under it. + """ + reasons = [] + if json_mode: + reasons.append("`--format json` (no terminal to prompt on)") + if ci: + reasons.append("`--ci`") + if non_interactive: + reasons.append("`--non-interactive`") + if not json_mode and not (sys.stdin.isatty() and sys.stderr.isatty()): + reasons.append("no interactive terminal (stdin/stderr not a tty -- piped, redirected, or CI)") + return Issue( + "doctor.fix-suppressed", + "warning", + "`--fix` was requested but not run: " + "; ".join(reasons) + ". Re-run " + "`tan doctor --fix` from a real, interactive terminal, without " + "--ci/--non-interactive/--format json, to allow it.", + ) + + def _collect( sdk_root: str | None, build: bool = False, @@ -2116,6 +2500,7 @@ def _collect( project_scope: str | None = None, workspace_root: str = ".", sdk_tier: str | None = None, + broken_global_default: str | None = None, ) -> list[Check]: """Every probe, in report order. Nothing here may raise -- see the module docstring; `probe`/`on_path`/`_read_text` are the only three ways this @@ -2155,6 +2540,12 @@ def _collect( it. Optional/defaulted for the same reason every other parameter here is: every existing direct caller keeps working, reporting `sdk` with no tier parenthetical rather than a guessed one. + + `broken_global_default` (tan-cli#344) -- the raw `sdkPath` a dangling + `~/.alp/sdk-default` pointer names, computed once by the caller + (`_broken_global_default`) and threaded straight to `sdk_check`. Optional/ + defaulted like `sdk_tier`; only changes the `sdk` check's remedy text, and + only in the branch `sdk_root is None` already reaches. """ checks: list[Check] = [] @@ -2188,7 +2579,11 @@ def _collect( _abs_posix(str(candidate)) ) != os.path.normcase(_abs_posix(sdk_root)): unselected_candidate = str(candidate) - checks.append(sdk_check(sdk_root, project_scope, sdk_tier, unselected_candidate)) + checks.append( + sdk_check( + sdk_root, project_scope, sdk_tier, unselected_candidate, broken_global_default + ) + ) project_selected = bool(project_scope and project_scope.strip()) or board_yaml is not None checks.append( board_yaml_preflight_check( @@ -2436,9 +2831,27 @@ def doctor( "this used to gate, now runs unconditionally, so this flag no longer " "changes the check list.", ), + fix: bool = typer.Option( + False, + "--fix", + help="Run the manifest's own install command (ADR 0021) for any " + "hostPrerequisites tool this host is missing, when it needs no " + "elevation. A command that needs `sudo` is printed, never run -- tan " + "never spawns sudo. Only in an interactive, non-CI, text-mode run " + "(--non-interactive/--ci/--format json all disable it): a repair a " + "human did not watch happen is not consent.", + ), output_format: str = typer.Option( "text", "--format", metavar="FORMAT", help="Output format: text or json." ), + non_interactive: bool = typer.Option( + False, + "--non-interactive", + help="Never prompt, and never run --fix's repairs -- see --fix.", + ), + ci: bool = typer.Option( + False, "--ci", help="CI mode: implies --non-interactive and disables --fix." + ), ) -> None: """Diagnose whether this host can build and flash.""" if output_format not in ("text", "json"): @@ -2478,6 +2891,11 @@ def doctor( resolved_sdk_root, sdk_tier, sdk_broken_pin = resolve_sdk_root_ladder(sdk_root, workspace_root) sdk_root = str(resolved_sdk_root) if resolved_sdk_root is not None else None sdk = SdkInfo(sdk_root, sdk_tier) if sdk_root is not None else None + # tan-cli#344: a dangling `~/.alp/sdk-default` is a distinct fact from + # "nothing configured" -- computed unconditionally (one small file read) + # so `sdk_check` can name it in the one branch (`sdk_root is None`) where + # the two used to print the identical sentence. + broken_global_default = _broken_global_default() # Forward slashes -- the established envelope contract on this seam # (`build_cmd.build`, `flash_cmd._resolve_project`), not the native # separators `str(Path(...))` would emit on Windows. @@ -2498,9 +2916,44 @@ def doctor( project_scope=project_scope, workspace_root=str(workspace_root), sdk_tier=sdk_tier, + broken_global_default=broken_global_default, ) + # tan-cli#91 / ADR 0021: `--fix` only ever RUNS anything when a human + # is demonstrably present. `doctor` otherwise only REPORTS; this flag + # turns it into a machine-global, network-fetching installer, so the + # consent gate is the feature, not decoration around it. + # + # Delegated to [`tan.core.consent.can_prompt`] rather than spelled out + # inline, because spelling it out inline is exactly how this went + # wrong: the hand-written form tested only the three FLAGS + # (`!non_interactive && !ci && !is_json`) and omitted the two + # `isatty()` calls, so a CI runner that redirected its output but did + # not happen to pass `--ci` got unattended host mutation -- measured + # under fully captured pipes, four real `winget install` runs with + # nobody watching. The oracle's own `--non-interactive` help states + # the missing half ("the same rule applies unasked when stdin or + # stderr is not a terminal -- piped, redirected, or a CI runner"). + # See that module for why BOTH handles matter, and why `stdout` + # deliberately does not. + fix_allowed = fix and can_prompt(non_interactive=non_interactive, ci=ci, json_mode=json_mode) + if fix_allowed: + missing_for_fix = next( + (c.missing for c in checks if c.name == "hostPrerequisites"), None + ) + if missing_for_fix: + checks = [*checks, *run_fix(missing_for_fix)] exit_code = exit_code_for(checks) issues = checks_to_issues(checks) + # tan-cli#91 P1: `--fix` requested and consent refused used to be a + # SILENT no-op, byte-for-byte identical to plain `tan doctor` -- + # measured against the oracle (`doctor --fix --format json`, which the + # oracle instead refuses to parse outright). SAY SO instead: name + # every condition of `can_prompt`'s that actually tripped. + if fix and not fix_allowed: + issues = [ + *issues, + fix_suppressed_issue(non_interactive=non_interactive, ci=ci, json_mode=json_mode), + ] # tan-cli#294 finding 4 (#203/#210, alp-sdk-vscode#347, ADR 0021 P0a): # `hostPrerequisites` is the only check that ever carries a # `{tool, command}` pair, so it is the only place this reads from -- @@ -2542,8 +2995,11 @@ def doctor( emit(Envelope("doctor", project, data, issues, exit_code, sdk=sdk)) else: for check in (data or {}).get("checks", []): - fix = f"\n fix: {check['fix']}" if "fix" in check else "" - print(f"[{check['status']:>7}] {check['name']}: {check['detail']}{fix}", file=sys.stderr) + # `fix_line`, never `fix`: this loop runs after the `fix: bool` + # parameter is done being read, but shadowing it here is a trap + # for the next edit that needs it further down. + fix_line = f"\n fix: {check['fix']}" if "fix" in check else "" + print(f"[{check['status']:>7}] {check['name']}: {check['detail']}{fix_line}", file=sys.stderr) if data is None: for issue in issues: print(f"{issue.severity}: {issue.message}", file=sys.stderr) @@ -2554,3 +3010,10 @@ def doctor( file=sys.stderr, ) raise typer.Exit(int(exit_code)) + + +# tan-cli#261: adds the five oracle `GlobalArgs` flags this command was still +# missing (`--all`/`--no-color`/`--quiet`/`--target`/`--verbose`) on top of +# `--non-interactive`/`--ci`, already declared and wired into `can_prompt` +# above; see `tan.core.global_flags`. +doctor = accept_global_flags(doctor) diff --git a/python/tan/commands/examples_cmd.py b/python/tan/commands/examples_cmd.py index 56485dd9..39ccccfa 100644 --- a/python/tan/commands/examples_cmd.py +++ b/python/tan/commands/examples_cmd.py @@ -52,6 +52,7 @@ from tan.commands.build_cmd import resolve_sdk_root_wide from tan.commands.sdk_cmd import project_pin_issue +from tan.core.global_flags import accept_global_flags from tan.envelope import Envelope, Issue, Project, SdkInfo, emit from tan.exit_codes import ExitCode @@ -401,3 +402,13 @@ def examples( for issue in issues: print(f"examples: {issue.message}", file=sys.stderr) raise typer.Exit(int(exit_code)) + + +# tan-cli#261: adds the seven oracle `GlobalArgs` flags this command was +# still missing (`--all`/`--board-yaml`/`--ci`/`--no-color`/ +# `--non-interactive`/`--quiet`/`--target`) on top of `--verbose`, already +# declared and read above; see `tan.core.global_flags`. Every one of them is +# genuinely inert for `examples` -- its envelope's `project` is always +# `Project(root=None, board_yaml=None)`, an SDK-wide catalogue with no +# project of its own to anchor a `--board-yaml`/`--target` on. +examples = accept_global_flags(examples) diff --git a/python/tan/commands/explain_cmd.py b/python/tan/commands/explain_cmd.py index 8f1c3597..7f0f95d6 100644 --- a/python/tan/commands/explain_cmd.py +++ b/python/tan/commands/explain_cmd.py @@ -57,6 +57,7 @@ import typer +from tan.core.global_flags import accept_global_flags from tan.core.scaffold import TemplateDataError, vendored_library_names_for from tan.envelope import Envelope, Issue, Project, emit from tan.exit_codes import ExitCode @@ -695,3 +696,13 @@ def _fail(json_mode: bool, err: ExplainError) -> None: [Issue(err.code, "error", err.message)], err.exit_code, ) + + +# tan-cli#261: adds the seven oracle `GlobalArgs` flags this command was +# still missing (`--all`/`--board-yaml`/`--ci`/`--no-color`/ +# `--non-interactive`/`--quiet`/`--verbose`) on top of `--target`, already +# declared and read above; see `tan.core.global_flags`. `--project`/ +# `--sdk-root` are ALSO declared already (accepted, not read -- see +# `explain`'s own docstring); the decorator leaves both untouched the same +# way it leaves `--target` untouched. +explain = accept_global_flags(explain) diff --git a/python/tan/commands/faultdecode_cmd.py b/python/tan/commands/faultdecode_cmd.py index 7864f912..c39c911d 100644 --- a/python/tan/commands/faultdecode_cmd.py +++ b/python/tan/commands/faultdecode_cmd.py @@ -189,17 +189,30 @@ def faultdecode( output_format: str = typer.Option( None, "--format", metavar="FORMAT", help="Output format: text or json." ), + board_yaml: str = typer.Option(None, "--board-yaml", hidden=True), + target: str = typer.Option(None, "--target", hidden=True), + all_targets: bool = typer.Option(False, "--all", hidden=True), + verbose: bool = typer.Option(False, "--verbose", hidden=True), + quiet: bool = typer.Option(False, "--quiet", hidden=True), + non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True), + ci: bool = typer.Option(False, "--ci", hidden=True), ) -> None: """Decode an ARM Cortex-M (ARMv8-M) fault dump. Supply registers as flags, and/or paste a dump via ``--file``/stdin and it greps the register names out. Explicit flags win over a parsed dump. - `--project`/`--sdk-root` are declared, not consumed: this command reads no - board.yaml and drives no alp-sdk checkout -- it is pure ARMv8-M register - arithmetic, same as the SDK original it replaces -- but tan's other - commands accept both as global flags, so a caller (or a saved script) that - passes them through unconditionally must not get a parse error. + `--project`/`--sdk-root`/`--board-yaml`/`--target`/`--all`/`--verbose`/ + `--quiet`/`--non-interactive`/`--ci` are declared, not consumed: this + command reads no board.yaml and drives no alp-sdk checkout -- it is pure + ARMv8-M register arithmetic, same as the SDK original it replaces -- but + the oracle's clap `GlobalArgs` are `global = true`, so every verb + (`faultdecode` included) accepts all of them; a caller (or a saved + script) that passes any through unconditionally must not get a parse + error -- `tan faultdecode --ci ...` exits the same with or without `--ci` + on the oracle. `--no-color` is the one exception in this group with real + meaning (see `_use_color`), and `--format` is documented separately + below. `--format` is accepted BEFORE the subcommand too (`tan --format json faultdecode ...`), same as `debug-config`; the root callback records it on @@ -211,6 +224,7 @@ def faultdecode( `--format json` is simply another spelling of `--json`, not a second, enveloped output shape. """ + del board_yaml, target, all_targets, verbose, quiet, non_interactive, ci resolved_format = output_format or (ctx.obj or {}).get("format") or "text" if resolved_format not in ("text", "json"): raise typer.BadParameter( diff --git a/python/tan/commands/flash_cmd.py b/python/tan/commands/flash_cmd.py index bbea811d..bc0525be 100644 --- a/python/tan/commands/flash_cmd.py +++ b/python/tan/commands/flash_cmd.py @@ -44,6 +44,7 @@ import functools import os +import re import subprocess import sys import tempfile @@ -67,6 +68,7 @@ FlashPlan, FlashPlanError, FlashTarget, + FlowDShape, ManifestError, backend_for, display_argv, @@ -84,6 +86,15 @@ select_flash_method, tool_gate, validate_flow_d_preflight_args, + validate_flow_d_shape, +) +from tan.core.global_flags import accept_global_flags +from tan.core.setools import ( + find_app_gen_toc, + missing_tool_message, + resolve_setools_dir, + sign_slot0, + unresolved_message, ) from tan.core.venv import prepend_path, tool_in_venv, venv_bin_dir, west_workspace_dir from tan.envelope import Envelope, Issue, Project, SdkInfo, emit @@ -660,6 +671,12 @@ class _Context: #: flash` can see alp-sdk's out-of-tree runners. `None` keeps the old #: app-dir cwd, matching the oracle exactly. workspace: str | None = None + #: `--setools-dir` (tan-cli#368) -- the HIGHEST-precedence SETOOLS + #: source, ahead of `SETOOLS_DIR` and `flash_args.setools_dir`; see + #: `tan.core.setools.resolve_setools_dir`. `None` when the flag was not + #: given, in which case resolution falls through to the other two exactly + #: as before. + setools_dir: str | None = None def _resolve_flow_d_atoc_address(flash_args: Any, build_root: str, sdk_root: str) -> Any: @@ -760,6 +777,109 @@ def _resolve_flow_d_atoc_path(flash_args: Any, build_root: str, sdk_root: str) - return merged +def _resolve_flow_d_atoc_via_setools( + flash_args: Any, shape: FlowDShape, ctx: _Context, entry_id: str +) -> tuple[Any, str | None]: + """tan-cli#353's remaining half: when Flow D still has no `atoc`/ + `atoc_address` after the explicit-value and `atoc_map` resolutions above + (`_resolve_flow_d_atoc_address`/`_resolve_flow_d_atoc_path`), sign one via + SETOOLS instead of handing `plan_alif_mram_jlink`'s bare "both required" + refusal to a customer who has never heard of `app-gen-toc`. Measured on + real silicon (e1m-aen-evk-01, E8 AE822): that refusal is exactly what a + fresh AEN801 manifest hits today, since alp-sdk's own emit carries only + `flash_args.jlink_flash_device`. + + `shape` is `validate_flow_d_shape`'s result -- the caller (`_flash_entry`, + tan-cli#366/#367) validates + resolves it BEFORE this function ever runs, + so `shape.artefact` is ALREADY the raw `.bin` to hand `app-gen-toc` (the + same ELF-only sibling resolution `plan_alif_mram_jlink` uses for the + eventual `loadbin`, from the ONE shared definition, + `flash_plan.resolve_slot0_binary`) and a manifest that would fail that + check has already failed BEFORE reaching this function, real run or + `--dry-run` alike -- this function no longer re-derives or re-checks + either. + + Returns `(flash_args, note)`. `note` is `None` on the one path that never + touched SETOOLS at all -- an already-resolved no-op (an explicit `atoc`/ + `atoc_map`, or `atoc_address` already present). Every path that DOES touch + SETOOLS returns a non-`None` `note` naming `setools.path`/`setools.source` + (tan-cli#373): under `--dry-run` it describes what WOULD be signed and + `flash_args` is left with `atoc`/`atoc_address` still absent (signing + writes real files into the customer's SETOOLS install and spawns a real + tool, which `--dry-run`'s "planning only" contract forbids regardless of + how harmless the ATOC step is next to the MRAM write it feeds); on a real + run it instead describes what WAS just signed, with `flash_args` fully + resolved for `plan_alif_mram_jlink` to consume. The caller (`_flash_entry`) + tells the two apart by `ctx.dry_run`, which it already has: the dry-run + note is the entry's own terminal message, the real-sign note is an EXTRA + line ahead of the real write's own ok/fail message -- previously + `setools.source` reached a customer only via a FAILURE + (`missing_tool_message`/`unresolved_message`), never on a run that + succeeded. + + Raises `FlashPlanError` for: SETOOLS unresolved, resolved but not a real + install, no `flash_args.slot0_load_address` to give `app-gen-toc` as its + `mramAddress`, or the sign step itself failing -- the caller's existing + `except FlashPlanError` arm (mirroring `_resolve_flow_d_atoc_address`/ + `_resolve_flow_d_atoc_path` above) reports it as the entry's + `flash.entry-failed` message. + + **Only when the manifest points at NOTHING signing-related at all.** A + customer who already supplied an explicit `atoc` (a blob they signed + themselves) or `atoc_map` (pointing at their own `app-gen-toc` run) gets + NONE of this -- even if that path did not fully resolve (e.g. the map has + not materialised yet) `plan_alif_mram_jlink`'s own precise refusal is the + right one, not a fresh SETOOLS sign silently overriding what they already + pointed tan at. + """ + if fa_str(flash_args, "atoc") is not None or fa_str(flash_args, "atoc_map") is not None: + return flash_args, None + if fa_str_checked(flash_args, "atoc_address", True) is not None: + return flash_args, None + + setools = resolve_setools_dir(flash_args, os.environ, ctx.setools_dir) + if setools is None: + raise FlashPlanError(unresolved_message()) + app_gen_toc = find_app_gen_toc(setools.path) + if app_gen_toc is None: + raise FlashPlanError(missing_tool_message(setools)) + + # `mramAddress` -- app-gen-toc's own placement for the app itself, distinct + # from `atoc_address` (the SIGNED PACKAGE's placement, derived below from + # its own build report). tan has no source for it besides this already- + # documented Flow D key (`plan_alif_mram_jlink`'s optional + # `slot0_load_address`, already extracted into `shape.app_address`) -- + # there is nothing to guess it from, so a manifest that omits it refuses + # here rather than falling through to a confusing generic message. + if shape.app_address is None: + raise FlashPlanError( + f"{FLOW_D_METHOD}: flash_args.slot0_load_address is required to auto-sign " + "via SETOOLS (it becomes app-gen-toc's mramAddress) -- supply the app's " + "real MRAM slot0 address, or sign by hand and set flash_args.atoc / " + "flash_args.atoc_address yourself." + ) + + if ctx.dry_run: + # Planning only -- report what WOULD be signed without touching the + # customer's SETOOLS install or spawning a real tool. + return flash_args, ( + f"would sign {shape.artefact} with SETOOLS at {setools.path} (via " + f"{setools.source}) -> build/config/{entry_id}-slot0.json, then run " + "app-gen-toc -- not run under --dry-run" + ) + + atoc_path, address = sign_slot0( + setools.path, app_gen_toc, shape.artefact, entry_id, shape.app_address + ) + merged = dict(flash_args) + merged["atoc"] = atoc_path + merged["atoc_address"] = address + return merged, ( + f"signed {shape.artefact} with SETOOLS at {setools.path} (via " + f"{setools.source}) -> {atoc_path} @ {address}" + ) + + def _flash_entry(target: FlashTarget, ctx: _Context) -> tuple[int, _Entry, list[str]]: """Dispatch + run one target. Returns `(rc, entry, text-lines)`.""" kind, entry_id = target.kind, target.id @@ -887,24 +1007,68 @@ def entry(method: str | None, status: str, rc: int, message: str) -> _Entry: return 1, entry(method, "failed", 1, gate.message), lines flash_args = target.flash_args + # Set only on the Flow D SETOOLS-auto-sign path below, and only when THIS + # run's own sign actually ran -- carried past the `if` block so the + # eventual success message (tan-cli#373) can name which SETOOLS install + # signed it, the same way `setools_note is not None` decides the + # `--dry-run` early return just below. + setools_note: str | None = None if method == FLOW_D_METHOD: - # The two places `flash_args` is augmented before dispatch: the ATOC - # address is a build-time output, so it may need resolving from a - # build artefact rather than arriving on the manifest already (see - # `_resolve_flow_d_atoc_address`; a supplied-but-unusable `atoc_map` - # raises there rather than silently deferring to `plan_alif_mram_jlink`'s - # generic refusal, caught here the same way `meta.build`'s is below) -- - # and the ATOC blob path itself is anchored on `build_root`/`sdk_root` - # (`_resolve_flow_d_atoc_path`) before it can reach the Commander - # script unresolved. + # FOUR things happen to `flash_args`/its shape before dispatch, in an + # order tan-cli#366/#367's review fixed: the ATOC address may need + # resolving from a build artefact rather than arriving on the + # manifest already (`_resolve_flow_d_atoc_address`; a + # supplied-but-unusable `atoc_map` raises there rather than silently + # deferring to `plan_alif_mram_jlink`'s generic refusal, caught here + # the same way); the ATOC blob path itself is anchored on + # `build_root`/`sdk_root` (`_resolve_flow_d_atoc_path`) before it can + # reach the Commander script unresolved; EVERYTHING ELSE about the + # entry's shape that does NOT depend on `atoc`/`atoc_address` -- + # `jlink_flash_device`, and (when armed) the slot0 artefact's own + # ELF-sibling-`.bin` resolution -- is validated NOW, via + # `validate_flow_d_shape`, the SAME function `plan_alif_mram_jlink` + # itself calls, so there is exactly one definition of "does this + # artefact resolve" (#367); the DPIDR preflight args are validated + # now too, for the same plan-time-not-real-write-time reason `tan + # flash --dry-run` needs any of this validated at all; and only THEN, + # tan-cli#353's remaining half, does SETOOLS sign one from scratch + # (`_resolve_flow_d_atoc_via_setools`) when the first two leave + # `atoc`/`atoc_address` still absent. + # + # **#366: this order is the fix.** A malformed/half-armed manifest + # used to be caught only by `meta.build`/`validate_flow_d_preflight_ + # args` FURTHER DOWN -- both unreachable from the `setools_note` + # early-return below, so a `--dry-run` whose `SETOOLS_DIR` happened to + # resolve reported `ok:true` for a manifest that would refuse a real + # (or SETOOLS-less) run outright. Validating first means that early + # return can only ever fire for an entry that has already passed + # everything checkable without `atoc`/`atoc_address`. try: flash_args = _resolve_flow_d_atoc_address(flash_args, ctx.build_root, ctx.sdk_root) flash_args = _resolve_flow_d_atoc_path(flash_args, ctx.build_root, ctx.sdk_root) + shape = validate_flow_d_shape(flash_args, artefact_path, _is_file) + validate_flow_d_preflight_args(flash_args) + flash_args, setools_note = _resolve_flow_d_atoc_via_setools( + flash_args, shape, ctx, entry_id + ) except FlashPlanError as err: msg = str(err) lines.append(f"flash: {kind} '{entry_id}' -> {method}") lines.append(f" FAIL: {msg}") return 1, entry(method, "failed", 1, msg), lines + if setools_note is not None and ctx.dry_run: + # `--dry-run` only (see the helper's own docstring): nothing was + # signed, so there is no `atoc`/`atoc_address` to hand + # `plan_alif_mram_jlink` -- report the preview directly rather + # than reaching its "both required" refusal over a field this + # entry was never asked to fill in by hand. A REAL sign (the + # `else` this `and ctx.dry_run` now excludes -- tan-cli#373) + # leaves `setools_note` set too, but must NOT return here: it + # falls through to `meta.build` like every other Flow D entry, + # carrying the note to the eventual ok message below instead. + lines.append(f"flash: {kind} '{entry_id}' -> {method}") + lines.append(f" {setools_note}") + return 0, entry(method, "ok", 0, setools_note), lines inputs = FlashInputs( artefact=artefact_path, @@ -924,23 +1088,6 @@ def entry(method: str | None, status: str, rc: int, message: str) -> _Entry: lines.append(f"flash: {kind} '{entry_id}' -> {method}") - # Flow D's DPIDR preflight args (`expect_dpidr`/`jlink_device`) are - # validated here too -- PLAN-TIME, before the confirm/dry-run gate below -- - # not only in `_flow_d_preflight` at real-write time. Without this, `tan - # flash --dry-run` (or any unconfirmed run) on a half-armed or malformed - # manifest reports `status: planned`/`ok` with no diagnostic, and the - # customer only learns their manifest is wrong once they actually confirm - # a write. This calls the same validate-only half `_flow_d_preflight` - # calls (via `flow_d_preflight_script`); it builds no script and touches - # no J-Link binary, so it is safe to run unconditionally here. - if method == FLOW_D_METHOD: - try: - validate_flow_d_preflight_args(flash_args) - except FlashPlanError as err: - msg = str(err) - lines.append(f" FAIL: {msg}") - return 1, entry(method, "failed", 1, msg), lines - if plan.planning_only or ctx.dry_run: shown = display_argv(plan) if ctx.dry_run: @@ -972,8 +1119,15 @@ def entry(method: str | None, status: str, rc: int, message: str) -> _Entry: outcome = _execute(plan, ctx.capture, ctx.venv_bin, ctx.workspace) if outcome.success: - lines.append(f" ok: {plan.ok_message}") - return 0, entry(method, "ok", 0, plan.ok_message), lines + # tan-cli#373: `setools_note` is set here only when THIS run's own + # SETOOLS auto-sign actually ran (the dry-run preview above always + # returns before reaching this line) -- prefixed onto the real + # write's own `ok_message` so `setools.source` reaches a customer on + # a SUCCESSFUL sign too, not only via `missing_tool_message`/ + # `unresolved_message` on a failure. + ok_message = f"{setools_note}; {plan.ok_message}" if setools_note else plan.ok_message + lines.append(f" ok: {ok_message}") + return 0, entry(method, "ok", 0, ok_message), lines msg = _execute_message(outcome, method, entry_id) lines.append(f" FAIL: {msg}") return 1, entry(method, "failed", 1, msg), lines @@ -1036,10 +1190,70 @@ def _flow_d_preflight( f"({outcome.stderr.strip() or 'probe silent'}); refusing to write MRAM " "without confirming which board is attached." ) + # `expected` is confirmed absent (checked above) -- but "absent" covers two + # measurably different banners (tan-cli#312): a connect that DID reach a + # board and reported some OTHER SW-DP ID (a real wrong-board / wiring / + # probe-selection problem), and a connect that reported no ID at all + # (measured on the rc3 bench: the probe still re-enumerating a few seconds + # after a prior `JLinkExe` close -- same probe, same cable, same + # `jlink_serial`, and nothing wrong with either). Both used to get the + # SAME wiring-and-jlink_serial sentence, which sent a user re-checking + # cables that were never the problem. + # + # Conservative on purpose: the "no ID at all" message below asserts the + # wiring is FINE, so it is only used when BOTH signals agree -- no + # DP-ID-shaped token anywhere in the banner, AND the banner carries + # SEGGER's own "the probe itself refused" wording. Anything the detector + # cannot place that confidently keeps the original sentence rather than + # guessing the wiring is innocent. + if not _dp_id_reported(banner) and _connect_failed_outright(banner): + return ( + f"{FLOW_D_METHOD}: the read-only DPIDR preflight's connect reported no " + f"SW-DP ID at all (expected {expected}) -- refusing to write MRAM to an " + "unidentified board. This looks like the J-Link probe still " + "re-enumerating after a previous JLinkExe session closed, not a wiring " + "or probe-selection problem -- wait a couple of seconds and retry." + ) + # tan-cli#373 (regression in #369): this point is reached by FOUR distinct + # banners, and only ONE of them is the cloned-serial mismatch #369 was + # filed over -- a board DID answer, with a DIFFERENT SW-DP ID than + # expected. #369's rewrite gave all four this one sentence, silently + # deleting the original wiring/`jlink_serial` advice for the other three + # (an unrecognised banner shape, and the two `_CONNECT_FAILED_TARGET_RE` + # shapes -- an unplugged ribbon's "Cannot connect to target." and a + # refused `jlink_serial`'s "Cannot connect to J-Link.") even though #369 + # scoped itself explicitly to the wrong-DP-ID case alone. + if _dp_id_reported(banner): + # A real board answered but with a DIFFERENT SW-DP ID than expected -- + # the bench mismatch #369 actually measured. A USB serial can be + # CLONED across two separate physical probes (a real OEM J-Link clone + # measured sharing one with a GD32 bridge probe on a different board + # entirely), so `jlink_serial` alone cannot disambiguate them even + # when set -- `JLinkExe` selects by serial only, with no USB-port + # selector. The SW-DP ID this preflight already reads IS the true + # per-silicon discriminator; this remediation says so instead of + # pointing at the one field that cannot fix this. + return ( + f"{FLOW_D_METHOD}: expected SW-DP IDR {expected} was not reported on connect " + "-- refusing to write MRAM to an unidentified board. Check the wiring and " + "which board is physically attached -- do NOT treat pinning " + "flash_args.jlink_serial alone as the fix: some OEM J-Link probes share a " + "CLONED serial across more than one physical unit, so jlink_serial cannot " + "disambiguate them even when set. The SW-DP ID is the real per-silicon " + "discriminator -- this preflight already checks it via " + "flash_args.expect_dpidr; if more than one board is reachable, confirm " + "which one answered by its own reported ID, not by serial alone." + ) + # An unrecognised banner, or a genuine TARGET-level connect refusal (no DP + # ID was even read to compare against the cloned-serial case above) -- + # `jlink_serial` pinning a probe IS the actual fix here when it is unset + # on a multi-probe host (tan-cli#353), so the original sentence survives. return ( f"{FLOW_D_METHOD}: expected SW-DP IDR {expected} was not reported on connect " "-- refusing to write MRAM to an unidentified board. Check the probe " - "selection (flash_args.jlink_serial) and the wiring." + "selection (flash_args.jlink_serial) and the wiring. If jlink_serial is " + "unset the script selects NO probe, which on a host carrying more than " + "one J-Link cannot connect at all (tan-cli#353)." ) @@ -1054,6 +1268,52 @@ def _hex_in(expected: str, haystack: str) -> bool: return needle in haystack.lower().replace("0x", "") +#: SEGGER's own wording for a successful SWD connect that read AN id, whatever +#: it turned out to be -- "Found SW-DP with ID 0x........" / "DPIDR: 0x........". +#: Matched loosely on purpose: what this distinguishes is "a real board +#: answered with a different identity" from "nothing answered", not the exact +#: firmware/DLL version's phrasing. +_DP_ID_RE = re.compile(r"(?:with\s+ID|DPIDR)\s*:?\s*0x[0-9A-Fa-f]+", re.IGNORECASE) + +#: SEGGER's own wording for the PROBE itself refusing the connection outright +#: -- measured verbatim on the rc3 bench run: "Connecting to J-Link ...FAILED: +#: Cannot connect to the probe/programmer." (tan-cli#312). Deliberately NOT a +#: bare `FAILED`/`Cannot connect` match (that was the tan-cli#312 review +#: finding): JLinkExe prints "FAILED" in many contexts, and "Cannot connect" +#: alone also fires on a TARGET-level refusal -- see `_CONNECT_FAILED_TARGET_RE` +#: below -- which is a real wiring/probe-selection problem, not a re-enumerating +#: probe. +_CONNECT_FAILED_RE = re.compile(r"Cannot connect to the probe/programmer", re.IGNORECASE) + +#: SEGGER's own wording for a TARGET-level connect refusal -- "Cannot connect +#: to target." (unplugged SWD ribbon, unpowered board) or "Cannot connect to +#: J-Link." (a probe that IS reachable via USB but refuses the requested +#: `jlink_serial`). Both are genuine wiring/probe-selection problems, so their +#: presence forces `_connect_failed_outright` to False even alongside the +#: probe-level phrase above -- asserting "wiring is fine" here would be the +#: false negative tan-cli#312's review flagged (measured against a real +#: unplugged-ribbon and a real unpowered-target banner). +_CONNECT_FAILED_TARGET_RE = re.compile(r"Cannot connect to (?:target|J-Link)\b", re.IGNORECASE) + + +def _dp_id_reported(banner: str) -> bool: + """Whether the banner names ANY SW-DP ID -- not whether it matches + `expected` (the caller already ruled that out via `_hex_in`), only whether + a connect got far enough to read one at all.""" + return _DP_ID_RE.search(banner) is not None + + +def _connect_failed_outright(banner: str) -> bool: + """Whether the banner carries SEGGER's own wording for the PROBE itself + refusing the connection (still re-enumerating, no board reachable at all), + as opposed to a TARGET-level refusal -- a real wiring/probe-selection + problem that must keep the original remediation, not the re-enumeration + one.""" + if _CONNECT_FAILED_TARGET_RE.search(banner) is not None: + return False + return _CONNECT_FAILED_RE.search(banner) is not None + + def _is_file(path: str) -> bool: """`Path::is_file`, incapable of raising -- it is called on manifest-supplied strings, which may hold a NUL byte or overlong component.""" @@ -1077,6 +1337,7 @@ def _run( skip_missing_tools: bool, capture: bool, cwd: str, + setools_dir_arg: str | None = None, ) -> tuple[ExitCode, dict[str, Any], list[Issue], list[str], SdkInfo | None]: """Everything between argument parsing and the envelope. Returns `(exit_code, data, issues, text_lines, sdk)`.""" @@ -1179,6 +1440,7 @@ def _run( capture=capture, venv_bin=venv_bin, workspace=workspace, + setools_dir=setools_dir_arg, ) for target in plan.targets: rc, entry, lines = _flash_entry(target, ctx) @@ -1314,6 +1576,16 @@ def flash( help="When a backend's required tools are all absent from PATH, warn + skip " "the entry instead of failing it. No effect under --dry-run.", ), + setools_dir: str = typer.Option( + None, + "--setools-dir", + metavar="PATH", + help="Alif SETOOLS install used to auto-sign a Flow D slot0 ATOC " + "(license-gated; obtained from Alif, never redistributed by tan). " + "Precedence: this flag, then the SETOOLS_DIR environment variable, " + "then flash_args.setools_dir in the manifest (lowest -- and rebuilt " + "over by the next `tan build`, see docs/setools.md).", + ), output_format: str = typer.Option( None, "--format", metavar="FORMAT", help="Output format: text or json." ), @@ -1354,6 +1626,7 @@ def flash( skip_missing_tools=skip_missing_tools, capture=json_mode, cwd=cwd, + setools_dir_arg=setools_dir, ) except Exception as err: # noqa: BLE001 -- the whole point of this guard # Anything reaching here is a tan bug, and it is reported AS ONE, with an @@ -1370,3 +1643,10 @@ def flash( for line in text_lines: print(line, file=sys.stderr) raise typer.Exit(int(exit_code)) + + +# tan-cli#261: adds the seven oracle `GlobalArgs` flags this command was +# still missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--quiet`/ +# `--target`/`--verbose`) on top of `--board-yaml`, already declared and read +# above; see `tan.core.global_flags`. +flash = accept_global_flags(flash) diff --git a/python/tan/commands/generate_cmd.py b/python/tan/commands/generate_cmd.py index d61c961a..db27737b 100644 --- a/python/tan/commands/generate_cmd.py +++ b/python/tan/commands/generate_cmd.py @@ -95,6 +95,7 @@ from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS, project_pin_issue from tan.commands.doctor_cmd import probe, resolve_manifest_python_floor from tan.core.fs_confine import PathEscapeError, resolve_confined +from tan.core.global_flags import accept_global_flags from tan.envelope import Envelope, Issue, Project, SdkInfo, emit from tan.exit_codes import ExitCode @@ -1026,3 +1027,11 @@ def refuse(err: GenerateError) -> None: engine=engine, exit_code=ExitCode.SUCCESS if not failed else ExitCode.WRITE_FAILURE, ) + + +# tan-cli#261: adds the two oracle `GlobalArgs` flags this command was still +# missing (`--ci`/`--no-color`) on top of the five already declared above +# (`--target`/`--all`/`--quiet`/`--verbose` read for real; `--non-interactive` +# accepted and dropped the same way these two now are); see +# `tan.core.global_flags`. +generate = accept_global_flags(generate) diff --git a/python/tan/commands/image_cmd.py b/python/tan/commands/image_cmd.py index 58e28f70..b44b69a8 100644 --- a/python/tan/commands/image_cmd.py +++ b/python/tan/commands/image_cmd.py @@ -70,6 +70,7 @@ resolve_project_context, ) from tan.commands.sdk_cmd import project_pin_issue +from tan.core.global_flags import accept_global_flags from tan.core.image_bundle import ( BUNDLE_DIR, BUNDLE_MANIFEST, @@ -537,3 +538,10 @@ def image( for line in outcome.text: stream.write(f"{line}\n") raise typer.Exit(int(outcome.exit_code)) + + +# tan-cli#261: adds the seven oracle `GlobalArgs` flags this command was +# still missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--quiet`/ +# `--target`/`--verbose`) on top of `--board-yaml`, already declared and read +# above; see `tan.core.global_flags`. +image = accept_global_flags(image) diff --git a/python/tan/commands/init_cmd.py b/python/tan/commands/init_cmd.py index d3f2e764..06c8d8c8 100644 --- a/python/tan/commands/init_cmd.py +++ b/python/tan/commands/init_cmd.py @@ -95,6 +95,7 @@ from tan.commands.build_cmd import resolve_sdk_root_wide from tan.core.fs_confine import PathEscapeError, resolve_confined +from tan.core.global_flags import accept_global_flags from tan.core.scaffold import ( DEFAULT_SOM_SKU, DEFAULT_TEMPLATE_ID, @@ -955,3 +956,10 @@ def init( return _emit_outcome(json_mode, outcome) + + +# tan-cli#261: adds the two oracle `GlobalArgs` flags this command was still +# missing (`--ci`/`--non-interactive`) on top of the five already declared +# above (`--verbose`/`--quiet`/`--no-color`/`--target`/`--all`, all read for +# real); see `tan.core.global_flags`. +init = accept_global_flags(init) diff --git a/python/tan/commands/inspect_cmd.py b/python/tan/commands/inspect_cmd.py new file mode 100644 index 00000000..d6d625d6 --- /dev/null +++ b/python/tan/commands/inspect_cmd.py @@ -0,0 +1,336 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan inspect` -- show resolved project/debug context values. + +Port of `crates/tan-cli/src/commands/inspect.rs` + the `tan_core::debug` +model it reads (`crates/tan-core/src/debug/{context,inspect}.rs`). Builds the +same six-row "resolved debug context" -- `workspaceRoot`, `sdkRoot`, +`boardYamlPath`, `boardYamlExists`, `westCwd`, `pythonBinary` -- that `tan +trace`/`tan support-bundle` (#257) also need. [`resolve_debug_project_context`] +and [`collect_resolved_values`] below are this port's ONE copy of that model: +`trace_cmd.py` and `support_bundle_cmd.py` import both directly rather than +re-deriving them, mirroring how the Rust `tan_core::debug` module is the one +place all three commands read it from. + +**Established by RUNNING the oracle (`target/debug/tan.exe`), not by reading +`crates/`** -- issues #258/#260/#261 all record source-reading alone producing +a wrong answer here twice already. Every shape below (the six rows, their +`source`/`detail` strings, the JSON key order, the mixed-separator `outputPath` +shape `trace`/`support-bundle` share) was measured against a freshly-built +oracle from THIS worktree's `crates/` (`cargo build -p alp-tan-cli --bin tan`), +not a possibly-stale `dev`-branch checkout's binary -- the two disagree on +`Project.boardYaml`'s existence-filtering (tan-cli#236, landed on this +worktree's branch, not yet on `dev`), which is exactly the kind of mismatch +RUNNING catches and reading `crates/` alone would not. + +**Which SDK ladder.** `inspect`/`trace` are two of the thirteen commands +`build_cmd.resolve_sdk_root_ladder`'s own docstring names -- measured against +the oracle -- as resolving the LATERAL/narrow ladder (the same one +`doctor_cmd.doctor` uses), not the wide `init`/`generate`/`examples`/`renode` +one. This file calls `resolve_sdk_root_ladder` directly for that reason, +rather than `build_output.resolve_project_context`'s narrower +`resolve_sdk_tiered` (no positional-walk tail) that `size`/`image`/ +`debug-config` use for their own, separately-documented reason. + +**Always-populated fields.** No `--west-cwd`/`--python-path` flag exists on +this CLI surface at all (the oracle's own `GlobalArgs` carries neither), so +`westCwd` always equals the resolved `workspaceRoot` and `pythonBinary` is +always the per-platform default (`python` on Windows, `python3` elsewhere) -- +verified against the oracle: every resolved-values row for these two keys +reports source `setting`/`default` respectively, in every argument shape +tried, never `unresolved`/`setting`-from-an-override. The Rust +`collect_resolved_values`'s "nothing resolved" branches for `workspaceRoot`/ +`boardYamlPath`/`westCwd` are therefore dead code in THIS port's construction +(the workspace root and the joined board.yaml path are always non-null once a +command reaches this point) and are not reproduced here. + +`tan inspect` itself has no failure exit in the oracle -- verified against +every argument shape tried (missing SDK, missing board.yaml, an empty +`--path`, `--path` matching nothing): always exit 0, with an `issues` entry +carrying the bad news instead. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import typer + +from tan.commands.build_cmd import _abs_posix, resolve_sdk_root_ladder +from tan.core.timestamp import generated_at_iso +from tan.envelope import Envelope, Issue, Project, SdkInfo, emit +from tan.exit_codes import ExitCode + +#: `data.schemaVersion` for this command's payload. +DATA_SCHEMA_VERSION = "1" + + +@dataclass(frozen=True) +class ResolvedDebugContext: + """The resolved project/debug context `inspect`/`trace`/`support-bundle` + all build from the same four CLI inputs (`--project`/`--board-yaml`/ + `--sdk-root`, plus the always-resolved host defaults). Mirrors the shape + `tan_core::debug::DebugWorkspaceContext` carries, minus the two fields + that only mean something inside an IDE extension host + (`project_selected`/`debugger_extensions`) -- the standalone CLI has no + reader for either (see `doctor_cmd`'s own note on the same gap).""" + + #: Posix, absolute -- always resolved (cwd, or `--project` joined onto it). + workspace_root: str + #: Posix, absolute, or `None` when no alp-sdk checkout resolved. + sdk_root: str | None + #: The `SdkSourceTier` string `resolve_sdk_root_ladder` answered with + #: (`"sdkRootFlag"`/`"projectPin"`/`"globalDefault"`/`"discovery"`/`"none"`). + sdk_tier: str + #: Posix, absolute -- the resolved location, joined onto `workspace_root` + #: unconditionally (mirrors `project.rs::resolve_board_yaml_path`): this is + #: WHERE a board.yaml would live, whether or not one is actually there. + board_yaml_path: str + #: Whether a real file sits at `board_yaml_path` (probed once, here). + board_yaml_exists: bool + #: Always equals `workspace_root` -- see the module docstring. + west_cwd: str + #: Always the per-platform default -- see the module docstring. + python_binary: str + #: The envelope `project` block (`board_yaml` present only when the file + #: really exists -- `Project.resolved` applies that filter). + project: Project + #: The envelope `sdk` block, or `None` when nothing resolved. + sdk: SdkInfo | None + + +def resolve_debug_project_context( + project_arg: str | None, board_yaml_arg: str | None, sdk_root_arg: str | None +) -> ResolvedDebugContext: + """Resolve `--project`/`--board-yaml`/`--sdk-root` into the shared debug + context. Mirrors `doctor_cmd.doctor`'s own workspace-root/board-yaml/SDK + preprocessing (same ladder, same `--board-yaml` anchoring rule) rather than + `build_output.resolve_project_context`'s -- see the module docstring for why + these two commands take the narrow ladder specifically. + """ + cwd = Path.cwd() + workspace_root_path = ( + cwd if project_arg is None else Path(os.path.join(str(cwd), project_arg)) + ) + workspace_root = _abs_posix(str(workspace_root_path)) + + configured = board_yaml_arg or "board.yaml" + if os.path.isabs(configured): + board_yaml_path = _abs_posix(configured) + else: + board_yaml_path = _abs_posix(os.path.join(str(workspace_root_path), configured)) + board_yaml_exists = os.path.isfile(board_yaml_path) + + resolved_sdk, sdk_tier, _broken_pin = resolve_sdk_root_ladder( + sdk_root_arg, workspace_root_path + ) + sdk_root = _abs_posix(str(resolved_sdk)) if resolved_sdk is not None else None + sdk = SdkInfo(sdk_root, sdk_tier) if sdk_root is not None else None + + python_binary = "python" if os.name == "nt" else "python3" + + return ResolvedDebugContext( + workspace_root=workspace_root, + sdk_root=sdk_root, + sdk_tier=sdk_tier, + board_yaml_path=board_yaml_path, + board_yaml_exists=board_yaml_exists, + west_cwd=workspace_root, + python_binary=python_binary, + project=Project.resolved(workspace_root, board_yaml_path), + sdk=sdk, + ) + + +def collect_resolved_values(context: ResolvedDebugContext) -> list[dict[str, Any]]: + """The six resolved-value rows, in the oracle's fixed order. Port of + `tan_core::debug::inspect::collect_resolved_values`, narrowed to the + branches this port's [`ResolvedDebugContext`] can actually produce -- see + the module docstring's "Always-populated fields" note.""" + return [ + { + "key": "workspaceRoot", + "value": context.workspace_root, + "source": "workspace", + "detail": "Resolved project directory (cwd, or --project ).", + }, + { + "key": "sdkRoot", + "value": context.sdk_root, + "source": "workspace" if context.sdk_root is not None else "unresolved", + "detail": ( + "Resolved alp-sdk root used for scripts and schemas." + if context.sdk_root is not None + else "Set with --sdk-root or `tan sdk switch ` when " + "automatic discovery is ambiguous." + ), + }, + { + "key": "boardYamlPath", + "value": context.board_yaml_path, + "source": "setting", + "detail": "Resolved board.yaml path (default location, or --board-yaml ).", + }, + { + "key": "boardYamlExists", + "value": context.board_yaml_exists, + "source": "runtime", + "detail": ( + "board.yaml exists at the resolved path." + if context.board_yaml_exists + else "board.yaml is missing at the resolved path." + ), + }, + { + "key": "westCwd", + "value": context.west_cwd, + "source": "setting", + "detail": "Working directory used for west commands.", + }, + { + "key": "pythonBinary", + "value": context.python_binary, + "source": "default", + "detail": "Interpreter used for loader and validation scripts.", + }, + ] + + +def filter_resolved_values( + values: list[dict[str, Any]], focus: str | None +) -> list[dict[str, Any]]: + """`focus is None` passes everything through; otherwise keep a value whose + `key` equals `focus` or is nested under it (a `focus.` dotted or `focus[` + indexed prefix) -- mirrors `inspect.rs::filter_resolved_values`.""" + if focus is None: + return values + dot = f"{focus}." + bracket = f"{focus}[" + return [ + v + for v in values + if v["key"] == focus or v["key"].startswith(dot) or v["key"].startswith(bracket) + ] + + +def _format_value_text(value: Any) -> str: + """Strings JSON-quoted, everything else its compact JSON form -- mirrors + Rust's `format_value` (`Value::String` -> `serde_json::to_string`, else + `Value::to_string()`); `json.dumps` gives the identical rendering for a + scalar (`true`/`false`/`null`/a bare number) in either language.""" + return json.dumps(value) + + +def _inspect_text_lines( + values: list[dict[str, Any]], focus: str | None, show_origin: bool, quiet: bool +) -> list[str]: + lines = [f"inspect: resolved values={len(values)}"] + if focus is not None: + lines.append(f"path={focus}") + if not quiet: + for v in values: + rendered = _format_value_text(v["value"]) + if show_origin: + lines.append( + f"{v['key']}={rendered} source={v['source']} " + f"detail={json.dumps(v['detail'])}" + ) + else: + lines.append(f"{v['key']}={rendered}") + return lines + + +def inspect( + ctx: typer.Context, + path: str = typer.Option( + None, "--path", metavar="PATH", help="Limit output to resolved values under this key path." + ), + project: str = typer.Option( + None, "--project", metavar="PATH", help="Project root (defaults to current directory)." + ), + board_yaml: str = typer.Option( + None, + "--board-yaml", + metavar="PATH", + help="Explicit board.yaml path (overrides project resolution).", + ), + show_origin: bool = typer.Option( + False, "--show-origin", help="Include source + detail metadata for each value." + ), + sdk_root: str = typer.Option( + None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." + ), + output_format: str = typer.Option( + None, "--format", metavar="FORMAT", help="Output format: text or json." + ), + quiet: bool = typer.Option(False, "--quiet", help="Suppress non-essential output."), + verbose: bool = typer.Option(False, "--verbose", hidden=True), + no_color: bool = typer.Option(False, "--no-color", hidden=True), + non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True), + ci: bool = typer.Option(False, "--ci", hidden=True), + target: str = typer.Option(None, "--target", hidden=True), + all_targets: bool = typer.Option(False, "--all", hidden=True), +) -> None: + """Inspect resolved project/debug context values. + + `--verbose`/`--no-color`/`--non-interactive`/`--ci`/`--target`/`--all` are + accepted and ignored: clap makes every one of them `global = true` in the + oracle, so `tan inspect --ci` (etc.) must not be a Click usage error even + though `inspect.rs` never reads any of them. + """ + del verbose, no_color, non_interactive, ci, target, all_targets + resolved_format = ( + output_format if output_format is not None else (ctx.obj or {}).get("format") or "text" + ) + if resolved_format not in ("text", "json"): + raise typer.BadParameter( + f"'{resolved_format}' (choose from 'text', 'json')", param_hint="--format" + ) + json_mode = resolved_format == "json" + + generated_at = generated_at_iso(millis=True) + context = resolve_debug_project_context(project, board_yaml, sdk_root) + + issues: list[Issue] = [] + if not context.board_yaml_exists: + issues.append( + Issue( + "inspect.board-yaml-missing", + "warning", + "board.yaml path could not be resolved or the file does not exist.", + ) + ) + + focus = path + values = filter_resolved_values(collect_resolved_values(context), focus) + if focus is not None and not values: + issues.append( + Issue( + "inspect.path-not-found", + "warning", + f"No resolved values match --path '{focus}'.", + ) + ) + + data = { + "schemaVersion": DATA_SCHEMA_VERSION, + "generatedAt": generated_at, + "focusPath": focus, + "showOrigin": show_origin, + "resolvedValues": values, + } + + if not json_mode: + for line in _inspect_text_lines(values, focus, show_origin, quiet): + typer.echo(line, err=True) + + if json_mode: + emit( + Envelope( + "inspect", context.project, data, issues, ExitCode.SUCCESS, sdk=context.sdk + ) + ) + raise typer.Exit(int(ExitCode.SUCCESS)) diff --git a/python/tan/commands/kconfig_cmd.py b/python/tan/commands/kconfig_cmd.py index 1bb0711c..d7561ceb 100644 --- a/python/tan/commands/kconfig_cmd.py +++ b/python/tan/commands/kconfig_cmd.py @@ -55,6 +55,7 @@ from tan.commands.presets_cmd import resolve_project_paths, resolve_sdk from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS +from tan.core.global_flags import accept_global_flags from tan.envelope import Envelope, Issue, Project, SdkInfo, emit from tan.exit_codes import ExitCode @@ -529,3 +530,10 @@ def kconfig( for line in _text_lines(data, verbose): print(line, file=sys.stderr) raise typer.Exit(int(ExitCode.SUCCESS)) + + +# tan-cli#261: adds the six oracle `GlobalArgs` flags this command was still +# missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--quiet`/ +# `--target`) on top of `--verbose`, already declared and read above; see +# `tan.core.global_flags`. +kconfig = accept_global_flags(kconfig) diff --git a/python/tan/commands/model_cmd.py b/python/tan/commands/model_cmd.py index 46700d41..646dc77f 100644 --- a/python/tan/commands/model_cmd.py +++ b/python/tan/commands/model_cmd.py @@ -1,506 +1,516 @@ -# SPDX-License-Identifier: Apache-2.0 -"""`tan model build` -- compile + package `board.yaml`'s `models:` block into -`.alpmodel` packages. - -Port of `scripts/alp_cli/model.py` (51 lines): the board.yaml discovery, -per-model source/compile-option path resolution, and the `built ` -summary all move here, in-process, exactly as they read there. What does NOT -move is `alp_model.build.build_model` itself -- the compiler-adapter engine -(CPU/Vela/DRP-AI/DeepX, `scripts/alp_model/`) that does the actual work. That -engine needs vendor NPU-compiler tooling only the SDK checkout's own Python -environment carries (DeepX's `dxcom` is license-gated), so this command -resolves the SDK checkout and its Python the same way `generate_cmd`'s -spawned-emitter escape hatch does, then runs ONE small driver script under it -(`_DRIVER`) that imports `alp_model.build` and calls it per model, reporting -back over stdout as one JSON document. - -This is a REAL implementation, not a forward: it never spawns `python -m -alp_cli`, so `alp_cli` stops being load-bearing for `tan model` (the point of -this port -- see `crates/tan-cli/src/commands/sdk_cli.rs`'s module doc for -what it is replacing). Unlike that Rust forwarder, a resolvable SDK is -required unconditionally -- `alp_model` lives under `/scripts`, and there -is no path that avoids importing it. - -**Deliberate divergence 1 from the oracle**: `alp_cli/model.py` has no -try/except around `build_model()` at all, so a build failure (e.g. "no blob -compiled for model") tracebacks the whole click command. Every command in -this port instead resolves to a coded issue, never a traceback (the -established rule -- see `generate_cmd`'s module doc) -- so a per-model -failure here is caught in the driver and reported as a `model.build-failed` -issue, and the run continues to the next model rather than aborting the -whole batch. - -**Deliberate divergence 2 from the oracle**: the oracle has no equivalent of -a spawned driver at all (it calls `build_model()` in-process), so it cannot -observe a driver that exits 0 having silently produced no result for a -declared model. This port can, and treats that as a failure: an empty/short -`_DRIVER` stdout is never coerced to `{}` (an empty document now falls -through to the same `JSONDecodeError` branch a malformed one already does), -and a driver that reports fewer `results` than models it was handed raises -`model.internal-failure` naming the missing model(s) rather than silently -reporting `built: []` -- indistinguishable otherwise from the legitimate -no-models no-op above. -""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -from pathlib import Path -from typing import Any - -import typer - -from tan.commands.build_cmd import _planner_python -from tan.commands.build_output import resolve_metadata_sdk_root, resolve_project_context -from tan.commands.doctor_cmd import resolve_manifest_python_floor -from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS -from tan.envelope import Envelope, Issue, Project, SdkInfo, emit -from tan.exit_codes import ExitCode - -#: `data.schemaVersion` for this command's payload. -DATA_SCHEMA_VERSION = "1" - -#: Seconds the compile driver may run. Generous -- a cold NPU-compiler -#: invocation (Vela, DRP-AI, DeepX) can be slow, and several models may be -#: queued in one run. Bounded regardless, so a wedged vendor tool cannot hang -#: a `--format json` consumer with no envelope and no error. -_BUILD_TIMEOUT_S = 1800 - -#: Driver run under the resolved SDK's Python, with `PYTHONPATH` pointed at -#: `/scripts` so `alp_model` resolves. Reads one JSON payload on stdin -#: (`{"models": [{"name", "source", "sku", "outDir", "metadataRoot", -#: "compileOpts"}]}`), writes one JSON document to stdout -#: (`{"results": [{"name", "ok", "path"|"error"}]}`). No argv, no env beyond -#: what the caller already sets -- keeping the driver's own surface to a -#: single stdin/stdout contract is what lets it stay this short. -_DRIVER = """ -import json, sys -from pathlib import Path - -payload = json.loads(sys.stdin.read()) -results = [] -try: - from alp_model.build import build_model -except Exception as err: - print(json.dumps({"importError": f"{type(err).__name__}: {err}"})) - sys.exit(0) - -for m in payload["models"]: - try: - out = build_model( - sku=payload["sku"], - name=m["name"], - source=Path(m["source"]), - out_dir=Path(payload["outDir"]), - metadata_root=Path(payload["metadataRoot"]), - compile_opts=m.get("compileOpts"), - ) - results.append({"name": m["name"], "ok": True, "path": str(out)}) - except Exception as err: - results.append({ - "name": m["name"], "ok": False, - "error": f"{type(err).__name__}: {err}", - }) -print(json.dumps({"results": results})) -""" - - -class ModelError(Exception): - """A refusal whose issue code and exit code are already decided.""" - - def __init__(self, code: str, message: str, exit_code: ExitCode) -> None: - super().__init__(message) - self.code = code - self.message = message - self.exit_code = exit_code - - -def _resolve_compile(block: dict | None, base: Path) -> dict | None: - """Port of `model.py::_resolve_compile`: every string value in each - per-backend compile block becomes an absolute path relative to the - `board.yaml` dir -- every current opts value is a path.""" - if not block: - return None - return { - backend: { - k: (str((base / v).resolve()) if isinstance(v, str) else v) - for k, v in (opts or {}).items() - } - for backend, opts in block.items() - } - - -def _load_board(path: Path) -> dict[str, Any]: - """`board.yaml` as a dict, or a `ModelError` for every way that can fail -- - missing file, bad encoding, not YAML, not a mapping. `yaml.safe_load`, - matching the oracle's own parse exactly (unlike `system_manifest`'s - core-schema loader, there is no serde_yaml parity requirement here).""" - try: - text = path.read_text(encoding="utf-8") - except OSError as err: - raise ModelError( - "model.board-yaml-missing", - f"board.yaml not found at {path}: {err}", - ExitCode.VALIDATION_FAILURE, - ) from err - try: - import yaml # noqa: PLC0415 (declared dependency, guarded anyway) - except ImportError as err: - raise ModelError( - "model.internal-failure", - f"no YAML parser available ({err}); install PyYAML (`pip install pyyaml`).", - ExitCode.INTERNAL_FAILURE, - ) from err - try: - doc = yaml.safe_load(text) - except Exception as err: # noqa: BLE001 -- any PyYAML failure is bad input - raise ModelError( - "model.board-yaml-invalid", f"{path}: {err}", ExitCode.VALIDATION_FAILURE - ) from err - if not isinstance(doc, dict): - raise ModelError( - "model.board-yaml-invalid", - f"{path}: expected a YAML mapping at the top level.", - ExitCode.VALIDATION_FAILURE, - ) - return doc - - -def _run_driver(python: str, sdk_scripts: Path, payload: dict) -> dict: - """Spawn `_DRIVER` under `python` with `/scripts` prepended to - `PYTHONPATH`, feed `payload` on stdin, and parse its one line of stdout. - Raises `ModelError` for every way the spawn itself can fail; a per-model - build failure is NOT one of those -- it comes back inside the parsed - result and is turned into an issue by the caller.""" - pythonpath = os.pathsep.join( - [str(sdk_scripts), *([p] if (p := os.environ.get("PYTHONPATH")) else [])] - ) - env = {**os.environ, "PYTHONPATH": pythonpath} - try: - out = subprocess.run( - [python, "-c", _DRIVER], - input=json.dumps(payload), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - env=env, - timeout=_BUILD_TIMEOUT_S, - check=False, - ) - except subprocess.TimeoutExpired as err: - raise ModelError( - "model.build-timeout", - f"model build timed out after {_BUILD_TIMEOUT_S}s.", - ExitCode.RUNTIME_FAILURE, - ) from err - except OSError as err: - raise ModelError( - "model.internal-failure", - f"failed to launch `{python}`: {err}", - ExitCode.RUNTIME_FAILURE, - ) from err - if out.returncode != 0: - stderr = (out.stderr or "").strip() - raise ModelError( - "model.internal-failure", - f"model build driver exited with code {out.returncode}: " - f"{stderr or '(no output)'}", - ExitCode.RUNTIME_FAILURE, - ) - # The last non-empty line, not the whole of stdout -- mirrors the same - # defence `_python_too_old` already applies one screen up in this file, - # against a future adapter `print()` or an inherited-stdout vendor tool - # polluting the one JSON document the driver is meant to write. Empty - # stdout (nothing printed at all -- a driver that silently produced - # nothing) falls through to `json.loads("")`, which raises - # `JSONDecodeError` below rather than being papered over as `{}`: a - # driver that exits 0 having produced nothing is a failure, not a - # legitimate no-op. - lines = [line for line in (out.stdout or "").splitlines() if line.strip()] - try: - return json.loads(lines[-1] if lines else "") - except json.JSONDecodeError as err: - raise ModelError( - "model.internal-failure", - f"model build driver produced unparsable output: {err}", - ExitCode.INTERNAL_FAILURE, - ) from err - - -def _python_too_old(python: str, floor: tuple[int, int]) -> str | None: - """A message when `python` is below `floor`, else `None` -- also for - "could not tell" (a missing/broken interpreter surfaces on its own at the - real spawn). Mirrors `generate_cmd._python_too_old`; `floor` is the - resolved SDK's OWN declared floor from - `doctor_cmd.resolve_manifest_python_floor` -- not a second hardcoded 3.10 - that could drift from the manifest's, or from `generate_cmd`'s own copy.""" - try: - out = subprocess.run( - [python, "-c", "import sys;print('%d.%d' % sys.version_info[:2])"], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - except (OSError, subprocess.SubprocessError, ValueError): - return None - if out.returncode != 0: - return None - try: - major, minor = (int(p) for p in out.stdout.strip().splitlines()[-1].split(".")[:2]) - except (IndexError, ValueError): - return None - if (major, minor) >= floor: - return None - return ( - f"Python {major}.{minor} found at `{python}`, but alp-sdk requires Python " - f"{floor[0]}.{floor[1]}+. Put a newer `python` first on PATH." - ) - - -def _run_build( - *, - board: str, - out: str, - metadata_root: str | None, - project: str | None, - sdk_root: str | None, -) -> tuple[Project, SdkInfo | None, dict, list[Issue], ExitCode]: - # `--board` plays the role `--board-yaml` does everywhere else, so the - # SAME project-context resolution applies (I-31); the envelope's `project` - # and `sdk` fields come from this ONE resolution, matching `size`/`image` - # (`build_output.resolve_project_context`'s own doc: the envelope's `sdk` - # block must be what THIS resolution produced, never a second lookup). - context = resolve_project_context(project, board, sdk_root) - workspace_root = Path(context.workspace_root) - board_path = Path(context.board_yaml) - reported_project = context.project() - sdk_info = context.sdk - - # The metadata-reading resolution is DELIBERATELY separate and wider - # (`build_output.resolve_metadata_sdk_root`'s own doc): a child/sibling - # checkout the project-context tier chain does not consider can still - # supply `alp_model`, in which case `sdk_info` stays absent while the - # build still runs against it -- same divergence `tan size`'s budget - # resolution already allows. - resolved_sdk = resolve_metadata_sdk_root(sdk_root, context.workspace_root) - if resolved_sdk is None: - raise ModelError( - "model.sdk-root-unresolved", - # `tan sdk switch` refuses in this build (tan-cli#305) -- kept the - # two mechanisms that actually work here (`--sdk-root`, placing - # the project near a checkout) and swapped the third for - # NO_SDK_NEXT_STEPS's honest "how to get one at all". - "alp-sdk root is unresolved. Use --sdk-root, place the project near an " - f"alp-sdk checkout, or {NO_SDK_NEXT_STEPS}.", - ExitCode.VALIDATION_FAILURE, - ) - - board_doc = _load_board(board_path) - som = board_doc.get("som") - sku = som.get("sku") if isinstance(som, dict) else None - if not isinstance(sku, str) or not sku: - raise ModelError( - "model.board-yaml-invalid", - f"{board_path}: som.sku is missing.", - ExitCode.VALIDATION_FAILURE, - ) - models = board_doc.get("models") or [] - if not isinstance(models, list): - raise ModelError( - "model.board-yaml-invalid", - f"{board_path}: `models:` must be a list.", - ExitCode.VALIDATION_FAILURE, - ) - - data: dict[str, Any] = {"schemaVersion": DATA_SCHEMA_VERSION, "sku": sku, "built": []} - if not models: - return reported_project, sdk_info, data, [], ExitCode.SUCCESS - - base = board_path.parent - out_dir = Path(out) - if not out_dir.is_absolute(): - out_dir = workspace_root / out_dir - metadata_dir = ( - Path(metadata_root) if metadata_root else resolved_sdk / "metadata" - ) - if metadata_root and not metadata_dir.is_absolute(): - metadata_dir = workspace_root / metadata_dir - - driver_models = [] - for m in models: - if not isinstance(m, dict) or "name" not in m or "source" not in m: - raise ModelError( - "model.board-yaml-invalid", - f"{board_path}: every `models:` entry needs `name` and `source`.", - ExitCode.VALIDATION_FAILURE, - ) - source = (base / m["source"]).resolve() - driver_models.append({ - "name": m["name"], - "source": str(source), - "compileOpts": _resolve_compile(m.get("compile"), base), - }) - - python = _planner_python(str(workspace_root), str(resolved_sdk)) - floor, _floor_source = resolve_manifest_python_floor(str(resolved_sdk)) - too_old = _python_too_old(python, floor) - if too_old is not None: - raise ModelError("model.python-too-old", too_old, ExitCode.RUNTIME_FAILURE) - - payload = { - "sku": sku, - "outDir": str(out_dir), - "metadataRoot": str(metadata_dir), - "models": driver_models, - } - result = _run_driver(python, resolved_sdk / "scripts", payload) - - if "importError" in result: - raise ModelError( - "model.internal-failure", - f"could not import alp_model from {resolved_sdk / 'scripts'}: " - f"{result['importError']}", - ExitCode.INTERNAL_FAILURE, - ) - - driver_results = result.get("results", []) - if len(driver_results) != len(driver_models): - # A driver that exits 0 but reports fewer results than models it was - # asked to build is a failure, not a partial success -- otherwise a - # wedged/short-circuited driver is indistinguishable from the - # legitimate no-models no-op (both would report `ok: true`). - reported = {r.get("name") for r in driver_results if isinstance(r, dict)} - missing = [m["name"] for m in driver_models if m["name"] not in reported] - raise ModelError( - "model.internal-failure", - f"model build driver reported {len(driver_results)} of " - f"{len(driver_models)} model(s); missing: {', '.join(missing)}.", - ExitCode.INTERNAL_FAILURE, - ) - - issues: list[Issue] = [] - built: list[str] = [] - for r in driver_results: - if r.get("ok"): - built.append(r["path"]) - else: - issues.append( - Issue( - "model.build-failed", - "error", - f"model '{r.get('name')}': {r.get('error', 'build failed')}", - ) - ) - data["built"] = built - exit_code = ExitCode.SUCCESS if not issues else ExitCode.WRITE_FAILURE - return reported_project, sdk_info, data, issues, exit_code - - -def model( - subcommand: str = typer.Argument(None, metavar="SUBCOMMAND", help="build."), - board: str = typer.Option( - "board.yaml", "--board", metavar="PATH", help="Path to board.yaml." - ), - out: str = typer.Option( - "build/models", "--out", metavar="PATH", help="Output directory." - ), - metadata_root: str = typer.Option( - None, - "--metadata-root", - metavar="PATH", - help="Path to the metadata/ root (default: /metadata).", - ), - project: str = typer.Option( - None, "--project", metavar="PATH", help="Project root (defaults to '.')." - ), - sdk_root: str = typer.Option( - None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." - ), - output_format: str = typer.Option( - "text", "--format", metavar="FORMAT", help="Output format: text or json." - ), -) -> None: - """Compile + package board.yaml `models:` into `.alpmodel` packages.""" - if output_format not in ("text", "json"): - raise typer.BadParameter( - f"'{output_format}' (choose from 'text', 'json')", param_hint="--format" - ) - json_mode = output_format == "json" - - def finish( - project_: Project, - sdk: SdkInfo | None, - data: dict, - issues: list[Issue], - exit_code: ExitCode, - ) -> None: - if json_mode: - emit(Envelope("model", project_, data, issues, exit_code, sdk=sdk)) - else: - for path in data.get("built", []): - print(f"built {path}", file=sys.stderr) - if not data.get("built") and not issues: - print( - "model: no `models:` declared in board.yaml; nothing to build.", - file=sys.stderr, - ) - for issue in issues: - print(f"model: {issue.message}", file=sys.stderr) - raise typer.Exit(int(exit_code)) - - if subcommand != "build": - finish( - Project(root=None, board_yaml=None), - None, - {"schemaVersion": DATA_SCHEMA_VERSION, "sku": None, "built": []}, - [ - Issue( - "model.unknown-subcommand", - "error", - f"Unknown model subcommand: {'(none)' if subcommand is None else subcommand}. " - "Available: build.", - ) - ], - ExitCode.RUNTIME_FAILURE, - ) - return - - try: - project_, sdk, data, issues, exit_code = _run_build( - board=board, - out=out, - metadata_root=metadata_root, - project=project, - sdk_root=sdk_root, - ) - except ModelError as err: - finish( - Project(root=None, board_yaml=None), - None, - {"schemaVersion": DATA_SCHEMA_VERSION, "sku": None, "built": []}, - [Issue(err.code, "error", err.message)], - err.exit_code, - ) - return - except Exception as err: # noqa: BLE001 -- the envelope IS the error contract - finish( - Project(root=None, board_yaml=None), - None, - {"schemaVersion": DATA_SCHEMA_VERSION, "sku": None, "built": []}, - [ - Issue( - "model.internal-failure", - "error", - f"model build failed unexpectedly: {type(err).__name__}: {err}", - ) - ], - ExitCode.INTERNAL_FAILURE, - ) - return - - finish(project_, sdk, data, issues, exit_code) +# SPDX-License-Identifier: Apache-2.0 +"""`tan model build` -- compile + package `board.yaml`'s `models:` block into +`.alpmodel` packages. + +Port of `scripts/alp_cli/model.py` (51 lines): the board.yaml discovery, +per-model source/compile-option path resolution, and the `built ` +summary all move here, in-process, exactly as they read there. What does NOT +move is `alp_model.build.build_model` itself -- the compiler-adapter engine +(CPU/Vela/DRP-AI/DeepX, `scripts/alp_model/`) that does the actual work. That +engine needs vendor NPU-compiler tooling only the SDK checkout's own Python +environment carries (DeepX's `dxcom` is license-gated), so this command +resolves the SDK checkout and its Python the same way `generate_cmd`'s +spawned-emitter escape hatch does, then runs ONE small driver script under it +(`_DRIVER`) that imports `alp_model.build` and calls it per model, reporting +back over stdout as one JSON document. + +This is a REAL implementation, not a forward: it never spawns `python -m +alp_cli`, so `alp_cli` stops being load-bearing for `tan model` (the point of +this port -- see `crates/tan-cli/src/commands/sdk_cli.rs`'s module doc for +what it is replacing). Unlike that Rust forwarder, a resolvable SDK is +required unconditionally -- `alp_model` lives under `/scripts`, and there +is no path that avoids importing it. + +**Deliberate divergence 1 from the oracle**: `alp_cli/model.py` has no +try/except around `build_model()` at all, so a build failure (e.g. "no blob +compiled for model") tracebacks the whole click command. Every command in +this port instead resolves to a coded issue, never a traceback (the +established rule -- see `generate_cmd`'s module doc) -- so a per-model +failure here is caught in the driver and reported as a `model.build-failed` +issue, and the run continues to the next model rather than aborting the +whole batch. + +**Deliberate divergence 2 from the oracle**: the oracle has no equivalent of +a spawned driver at all (it calls `build_model()` in-process), so it cannot +observe a driver that exits 0 having silently produced no result for a +declared model. This port can, and treats that as a failure: an empty/short +`_DRIVER` stdout is never coerced to `{}` (an empty document now falls +through to the same `JSONDecodeError` branch a malformed one already does), +and a driver that reports fewer `results` than models it was handed raises +`model.internal-failure` naming the missing model(s) rather than silently +reporting `built: []` -- indistinguishable otherwise from the legitimate +no-models no-op above. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import typer + +from tan.commands.build_cmd import _planner_python +from tan.commands.build_output import resolve_metadata_sdk_root, resolve_project_context +from tan.commands.doctor_cmd import resolve_manifest_python_floor +from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS +from tan.core.global_flags import accept_global_flags +from tan.envelope import Envelope, Issue, Project, SdkInfo, emit +from tan.exit_codes import ExitCode + +#: `data.schemaVersion` for this command's payload. +DATA_SCHEMA_VERSION = "1" + +#: Seconds the compile driver may run. Generous -- a cold NPU-compiler +#: invocation (Vela, DRP-AI, DeepX) can be slow, and several models may be +#: queued in one run. Bounded regardless, so a wedged vendor tool cannot hang +#: a `--format json` consumer with no envelope and no error. +_BUILD_TIMEOUT_S = 1800 + +#: Driver run under the resolved SDK's Python, with `PYTHONPATH` pointed at +#: `/scripts` so `alp_model` resolves. Reads one JSON payload on stdin +#: (`{"models": [{"name", "source", "sku", "outDir", "metadataRoot", +#: "compileOpts"}]}`), writes one JSON document to stdout +#: (`{"results": [{"name", "ok", "path"|"error"}]}`). No argv, no env beyond +#: what the caller already sets -- keeping the driver's own surface to a +#: single stdin/stdout contract is what lets it stay this short. +_DRIVER = """ +import json, sys +from pathlib import Path + +payload = json.loads(sys.stdin.read()) +results = [] +try: + from alp_model.build import build_model +except Exception as err: + print(json.dumps({"importError": f"{type(err).__name__}: {err}"})) + sys.exit(0) + +for m in payload["models"]: + try: + out = build_model( + sku=payload["sku"], + name=m["name"], + source=Path(m["source"]), + out_dir=Path(payload["outDir"]), + metadata_root=Path(payload["metadataRoot"]), + compile_opts=m.get("compileOpts"), + ) + results.append({"name": m["name"], "ok": True, "path": str(out)}) + except Exception as err: + results.append({ + "name": m["name"], "ok": False, + "error": f"{type(err).__name__}: {err}", + }) +print(json.dumps({"results": results})) +""" + + +class ModelError(Exception): + """A refusal whose issue code and exit code are already decided.""" + + def __init__(self, code: str, message: str, exit_code: ExitCode) -> None: + super().__init__(message) + self.code = code + self.message = message + self.exit_code = exit_code + + +def _resolve_compile(block: dict | None, base: Path) -> dict | None: + """Port of `model.py::_resolve_compile`: every string value in each + per-backend compile block becomes an absolute path relative to the + `board.yaml` dir -- every current opts value is a path.""" + if not block: + return None + return { + backend: { + k: (str((base / v).resolve()) if isinstance(v, str) else v) + for k, v in (opts or {}).items() + } + for backend, opts in block.items() + } + + +def _load_board(path: Path) -> dict[str, Any]: + """`board.yaml` as a dict, or a `ModelError` for every way that can fail -- + missing file, bad encoding, not YAML, not a mapping. `yaml.safe_load`, + matching the oracle's own parse exactly (unlike `system_manifest`'s + core-schema loader, there is no serde_yaml parity requirement here).""" + try: + text = path.read_text(encoding="utf-8") + except OSError as err: + raise ModelError( + "model.board-yaml-missing", + f"board.yaml not found at {path}: {err}", + ExitCode.VALIDATION_FAILURE, + ) from err + try: + import yaml # noqa: PLC0415 (declared dependency, guarded anyway) + except ImportError as err: + raise ModelError( + "model.internal-failure", + f"no YAML parser available ({err}); install PyYAML (`pip install pyyaml`).", + ExitCode.INTERNAL_FAILURE, + ) from err + try: + doc = yaml.safe_load(text) + except Exception as err: # noqa: BLE001 -- any PyYAML failure is bad input + raise ModelError( + "model.board-yaml-invalid", f"{path}: {err}", ExitCode.VALIDATION_FAILURE + ) from err + if not isinstance(doc, dict): + raise ModelError( + "model.board-yaml-invalid", + f"{path}: expected a YAML mapping at the top level.", + ExitCode.VALIDATION_FAILURE, + ) + return doc + + +def _run_driver(python: str, sdk_scripts: Path, payload: dict) -> dict: + """Spawn `_DRIVER` under `python` with `/scripts` prepended to + `PYTHONPATH`, feed `payload` on stdin, and parse its one line of stdout. + Raises `ModelError` for every way the spawn itself can fail; a per-model + build failure is NOT one of those -- it comes back inside the parsed + result and is turned into an issue by the caller.""" + pythonpath = os.pathsep.join( + [str(sdk_scripts), *([p] if (p := os.environ.get("PYTHONPATH")) else [])] + ) + env = {**os.environ, "PYTHONPATH": pythonpath} + try: + out = subprocess.run( + [python, "-c", _DRIVER], + input=json.dumps(payload), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + timeout=_BUILD_TIMEOUT_S, + check=False, + ) + except subprocess.TimeoutExpired as err: + raise ModelError( + "model.build-timeout", + f"model build timed out after {_BUILD_TIMEOUT_S}s.", + ExitCode.RUNTIME_FAILURE, + ) from err + except OSError as err: + raise ModelError( + "model.internal-failure", + f"failed to launch `{python}`: {err}", + ExitCode.RUNTIME_FAILURE, + ) from err + if out.returncode != 0: + stderr = (out.stderr or "").strip() + raise ModelError( + "model.internal-failure", + f"model build driver exited with code {out.returncode}: " + f"{stderr or '(no output)'}", + ExitCode.RUNTIME_FAILURE, + ) + # The last non-empty line, not the whole of stdout -- mirrors the same + # defence `_python_too_old` already applies one screen up in this file, + # against a future adapter `print()` or an inherited-stdout vendor tool + # polluting the one JSON document the driver is meant to write. Empty + # stdout (nothing printed at all -- a driver that silently produced + # nothing) falls through to `json.loads("")`, which raises + # `JSONDecodeError` below rather than being papered over as `{}`: a + # driver that exits 0 having produced nothing is a failure, not a + # legitimate no-op. + lines = [line for line in (out.stdout or "").splitlines() if line.strip()] + try: + return json.loads(lines[-1] if lines else "") + except json.JSONDecodeError as err: + raise ModelError( + "model.internal-failure", + f"model build driver produced unparsable output: {err}", + ExitCode.INTERNAL_FAILURE, + ) from err + + +def _python_too_old(python: str, floor: tuple[int, int]) -> str | None: + """A message when `python` is below `floor`, else `None` -- also for + "could not tell" (a missing/broken interpreter surfaces on its own at the + real spawn). Mirrors `generate_cmd._python_too_old`; `floor` is the + resolved SDK's OWN declared floor from + `doctor_cmd.resolve_manifest_python_floor` -- not a second hardcoded 3.10 + that could drift from the manifest's, or from `generate_cmd`'s own copy.""" + try: + out = subprocess.run( + [python, "-c", "import sys;print('%d.%d' % sys.version_info[:2])"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.SubprocessError, ValueError): + return None + if out.returncode != 0: + return None + try: + major, minor = (int(p) for p in out.stdout.strip().splitlines()[-1].split(".")[:2]) + except (IndexError, ValueError): + return None + if (major, minor) >= floor: + return None + return ( + f"Python {major}.{minor} found at `{python}`, but alp-sdk requires Python " + f"{floor[0]}.{floor[1]}+. Put a newer `python` first on PATH." + ) + + +def _run_build( + *, + board: str, + out: str, + metadata_root: str | None, + project: str | None, + sdk_root: str | None, +) -> tuple[Project, SdkInfo | None, dict, list[Issue], ExitCode]: + # `--board` plays the role `--board-yaml` does everywhere else, so the + # SAME project-context resolution applies (I-31); the envelope's `project` + # and `sdk` fields come from this ONE resolution, matching `size`/`image` + # (`build_output.resolve_project_context`'s own doc: the envelope's `sdk` + # block must be what THIS resolution produced, never a second lookup). + context = resolve_project_context(project, board, sdk_root) + workspace_root = Path(context.workspace_root) + board_path = Path(context.board_yaml) + reported_project = context.project() + sdk_info = context.sdk + + # The metadata-reading resolution is DELIBERATELY separate and wider + # (`build_output.resolve_metadata_sdk_root`'s own doc): a child/sibling + # checkout the project-context tier chain does not consider can still + # supply `alp_model`, in which case `sdk_info` stays absent while the + # build still runs against it -- same divergence `tan size`'s budget + # resolution already allows. + resolved_sdk = resolve_metadata_sdk_root(sdk_root, context.workspace_root) + if resolved_sdk is None: + raise ModelError( + "model.sdk-root-unresolved", + # `tan sdk switch` refuses in this build (tan-cli#305) -- kept the + # two mechanisms that actually work here (`--sdk-root`, placing + # the project near a checkout) and swapped the third for + # NO_SDK_NEXT_STEPS's honest "how to get one at all". + "alp-sdk root is unresolved. Use --sdk-root, place the project near an " + f"alp-sdk checkout, or {NO_SDK_NEXT_STEPS}.", + ExitCode.VALIDATION_FAILURE, + ) + + board_doc = _load_board(board_path) + som = board_doc.get("som") + sku = som.get("sku") if isinstance(som, dict) else None + if not isinstance(sku, str) or not sku: + raise ModelError( + "model.board-yaml-invalid", + f"{board_path}: som.sku is missing.", + ExitCode.VALIDATION_FAILURE, + ) + models = board_doc.get("models") or [] + if not isinstance(models, list): + raise ModelError( + "model.board-yaml-invalid", + f"{board_path}: `models:` must be a list.", + ExitCode.VALIDATION_FAILURE, + ) + + data: dict[str, Any] = {"schemaVersion": DATA_SCHEMA_VERSION, "sku": sku, "built": []} + if not models: + return reported_project, sdk_info, data, [], ExitCode.SUCCESS + + base = board_path.parent + out_dir = Path(out) + if not out_dir.is_absolute(): + out_dir = workspace_root / out_dir + metadata_dir = ( + Path(metadata_root) if metadata_root else resolved_sdk / "metadata" + ) + if metadata_root and not metadata_dir.is_absolute(): + metadata_dir = workspace_root / metadata_dir + + driver_models = [] + for m in models: + if not isinstance(m, dict) or "name" not in m or "source" not in m: + raise ModelError( + "model.board-yaml-invalid", + f"{board_path}: every `models:` entry needs `name` and `source`.", + ExitCode.VALIDATION_FAILURE, + ) + source = (base / m["source"]).resolve() + driver_models.append({ + "name": m["name"], + "source": str(source), + "compileOpts": _resolve_compile(m.get("compile"), base), + }) + + python = _planner_python(str(workspace_root), str(resolved_sdk)) + floor, _floor_source = resolve_manifest_python_floor(str(resolved_sdk)) + too_old = _python_too_old(python, floor) + if too_old is not None: + raise ModelError("model.python-too-old", too_old, ExitCode.RUNTIME_FAILURE) + + payload = { + "sku": sku, + "outDir": str(out_dir), + "metadataRoot": str(metadata_dir), + "models": driver_models, + } + result = _run_driver(python, resolved_sdk / "scripts", payload) + + if "importError" in result: + raise ModelError( + "model.internal-failure", + f"could not import alp_model from {resolved_sdk / 'scripts'}: " + f"{result['importError']}", + ExitCode.INTERNAL_FAILURE, + ) + + driver_results = result.get("results", []) + if len(driver_results) != len(driver_models): + # A driver that exits 0 but reports fewer results than models it was + # asked to build is a failure, not a partial success -- otherwise a + # wedged/short-circuited driver is indistinguishable from the + # legitimate no-models no-op (both would report `ok: true`). + reported = {r.get("name") for r in driver_results if isinstance(r, dict)} + missing = [m["name"] for m in driver_models if m["name"] not in reported] + raise ModelError( + "model.internal-failure", + f"model build driver reported {len(driver_results)} of " + f"{len(driver_models)} model(s); missing: {', '.join(missing)}.", + ExitCode.INTERNAL_FAILURE, + ) + + issues: list[Issue] = [] + built: list[str] = [] + for r in driver_results: + if r.get("ok"): + built.append(r["path"]) + else: + issues.append( + Issue( + "model.build-failed", + "error", + f"model '{r.get('name')}': {r.get('error', 'build failed')}", + ) + ) + data["built"] = built + exit_code = ExitCode.SUCCESS if not issues else ExitCode.WRITE_FAILURE + return reported_project, sdk_info, data, issues, exit_code + + +def model( + subcommand: str = typer.Argument(None, metavar="SUBCOMMAND", help="build."), + board: str = typer.Option( + "board.yaml", "--board", metavar="PATH", help="Path to board.yaml." + ), + out: str = typer.Option( + "build/models", "--out", metavar="PATH", help="Output directory." + ), + metadata_root: str = typer.Option( + None, + "--metadata-root", + metavar="PATH", + help="Path to the metadata/ root (default: /metadata).", + ), + project: str = typer.Option( + None, "--project", metavar="PATH", help="Project root (defaults to '.')." + ), + sdk_root: str = typer.Option( + None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." + ), + output_format: str = typer.Option( + "text", "--format", metavar="FORMAT", help="Output format: text or json." + ), +) -> None: + """Compile + package board.yaml `models:` into `.alpmodel` packages.""" + if output_format not in ("text", "json"): + raise typer.BadParameter( + f"'{output_format}' (choose from 'text', 'json')", param_hint="--format" + ) + json_mode = output_format == "json" + + def finish( + project_: Project, + sdk: SdkInfo | None, + data: dict, + issues: list[Issue], + exit_code: ExitCode, + ) -> None: + if json_mode: + emit(Envelope("model", project_, data, issues, exit_code, sdk=sdk)) + else: + for path in data.get("built", []): + print(f"built {path}", file=sys.stderr) + if not data.get("built") and not issues: + print( + "model: no `models:` declared in board.yaml; nothing to build.", + file=sys.stderr, + ) + for issue in issues: + print(f"model: {issue.message}", file=sys.stderr) + raise typer.Exit(int(exit_code)) + + if subcommand != "build": + finish( + Project(root=None, board_yaml=None), + None, + {"schemaVersion": DATA_SCHEMA_VERSION, "sku": None, "built": []}, + [ + Issue( + "model.unknown-subcommand", + "error", + f"Unknown model subcommand: {'(none)' if subcommand is None else subcommand}. " + "Available: build.", + ) + ], + ExitCode.RUNTIME_FAILURE, + ) + return + + try: + project_, sdk, data, issues, exit_code = _run_build( + board=board, + out=out, + metadata_root=metadata_root, + project=project, + sdk_root=sdk_root, + ) + except ModelError as err: + finish( + Project(root=None, board_yaml=None), + None, + {"schemaVersion": DATA_SCHEMA_VERSION, "sku": None, "built": []}, + [Issue(err.code, "error", err.message)], + err.exit_code, + ) + return + except Exception as err: # noqa: BLE001 -- the envelope IS the error contract + finish( + Project(root=None, board_yaml=None), + None, + {"schemaVersion": DATA_SCHEMA_VERSION, "sku": None, "built": []}, + [ + Issue( + "model.internal-failure", + "error", + f"model build failed unexpectedly: {type(err).__name__}: {err}", + ) + ], + ExitCode.INTERNAL_FAILURE, + ) + return + + finish(project_, sdk, data, issues, exit_code) + + +# tan-cli#261: adds the eight oracle `GlobalArgs` flags this command was +# missing entirely (`--all`/`--board-yaml`/`--ci`/`--no-color`/ +# `--non-interactive`/`--quiet`/`--target`/`--verbose`); see +# `tan.core.global_flags`. All inert here: `model`'s own `--board` already +# plays `--board-yaml`'s role for real (see `_run_build`'s comment), so the +# newly-accepted `--board-yaml` is never consulted. +model = accept_global_flags(model) diff --git a/python/tan/commands/monitor_cmd.py b/python/tan/commands/monitor_cmd.py index b1aff8a3..b4a29aa5 100644 --- a/python/tan/commands/monitor_cmd.py +++ b/python/tan/commands/monitor_cmd.py @@ -1,245 +1,283 @@ -# SPDX-License-Identifier: Apache-2.0 -"""`tan monitor` -- open a serial console to the attached board. - -Port of `scripts/alp_cli/monitor.py` (73 lines): a thin front door over -pyserial's `miniterm`. Port comes from `--port`; baud from `--baud` (default -115200, the SDK-wide console default). - -There is no safe cross-platform guess for the port itself (COMx vs -`/dev/ttyUSBx` vs `/dev/cu.*`), so when no port is given -- or the requested -one does not exist -- this command lists every serial port pyserial can see -and refuses instead of hanging on a wrong device. - -Board-context port resolution (a `console:` block in the project's -`system-manifest.yaml`) is deliberately NOT implemented here either: the board -schema and orchestrator do not emit one today. Teach this verb to read it once -they do. - -**No alp-sdk checkout required, unlike `model`.** The oracle's `monitor.py` -imports nothing from alp-sdk beyond `alp_cli._workspace.python_exe`, itself -just `sys.executable` -- the running interpreter. This port does NOT read -`sys.executable` directly, though: under PyInstaller `sys.executable` IS -`tan` itself, so spawning it would just re-enter this CLI instead of -launching miniterm -- the same reasoning `build_cmd.py`'s `_planner_python` -and `generate_cmd.py` already carry, spelled out there so it need not be -re-argued per call site. This port reuses that same function, a PATH name -(`python`/`python3`) never `sys.executable`, when frozen or when -`sys.executable` is empty (an embedded interpreter can report ""); the -running interpreter is still preferred otherwise, since it is guaranteed to -have `serial` importable already. Either way no SDK root is resolved, so -`tan monitor` no longer requires a resolvable alp-sdk checkout the way the -retired Rust forwarder did (`crates/tan-cli/src/commands/sdk_cli.rs` resolves -one unconditionally for every forward, `monitor` included, purely as an -artifact of sharing one function with `model`/`new-som`/`faultdecode`) -- a -deliberate, documented improvement, not a regression: `monitor` never read -anything an SDK root would supply. - -**Exit code on a failed miniterm run is `RuntimeFailure` (1) regardless of the -child's own exit code** -- mirroring the shipped Rust forwarder -(`sdk_cli::run`'s `s.code().unwrap_or(1)` branch always maps to -`ExitCode::RuntimeFailure`), which is the customer-facing contract today, NOT -the oracle's literal `raise SystemExit(rc)` passthrough of whatever code -miniterm returned. The actual child code still reaches the issue message. -""" - -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path - -import typer - -from tan.commands.build_cmd import _planner_python -from tan.envelope import Envelope, Issue, Project, emit -from tan.exit_codes import ExitCode - -#: The SDK-wide console default, matching `monitor.py::DEFAULT_BAUD`. -DEFAULT_BAUD = 115200 - -#: `data.schemaVersion` for this command's payload. -DATA_SCHEMA_VERSION = "1" - - -class MonitorError(Exception): - """A refusal whose issue code and exit code are already decided.""" - - def __init__(self, code: str, message: str, exit_code: ExitCode, data: dict) -> None: - super().__init__(message) - self.code = code - self.message = message - self.exit_code = exit_code - self.data = data - - -def _pyserial_missing() -> MonitorError: - """The one spelling of "pyserial is not installed". - - The hint names the EXTRA rather than the bare distribution because that is - the supported way to get it: pyserial is declared in - `[project.optional-dependencies] monitor`, not in `dependencies`. A frozen - `--onefile` build resolves it at BUILD time, so a customer holding a binary - built without the extra cannot pip-install their way out -- hence the second - sentence, which is the only actionable thing to tell them. - """ - return MonitorError( - "monitor.pyserial-missing", - "pyserial is required for `tan monitor`. Install it with " - '`pip install "alp-tan[monitor]"`. A frozen `tan` binary bundles it at ' - "build time, so a binary built without that extra cannot gain it here.", - ExitCode.RUNTIME_FAILURE, - {"schemaVersion": DATA_SCHEMA_VERSION}, - ) - - -def _available_ports() -> list[tuple[str, str]]: - """`[(device, description)]` for every serial port pyserial can see. - - The import is guarded HERE, not only at the caller, because this is the one - choke point every port-listing path routes through -- and because - `_run_monitor`'s precheck is deliberately skipped on a FROZEN build (there - is no `sys.executable` worth validating there). On a `--onefile` binary - built without the `monitor` extra this line is therefore the FIRST place - pyserial is touched, and it is reached IN-PROCESS before any child is - spawned. Left unguarded the ImportError escaped as an unexpected exception - and surfaced as `monitor.internal-failure` at exit 5 -- "tan has a bug" -- - for what is simply an optional dependency the customer never installed. - """ - try: - from serial.tools import list_ports # noqa: PLC0415 (optional at runtime) - except ImportError as err: - raise _pyserial_missing() from err - - return [(p.device, p.description or "") for p in list_ports.comports()] - - -def _ports_data(ports: list[tuple[str, str]]) -> list[dict[str, str]]: - return [{"device": device, "description": description} for device, description in ports] - - -def _refuse_listing_ports(reason: str) -> MonitorError: - """Port of `monitor.py::_die_listing_ports`: the reason plus every serial - port pyserial can see, folded into one issue message so `--format json` - carries the same information the oracle prints line-by-line to stderr.""" - ports = _available_ports() - if ports: - listing = "; ".join(f"{d} {desc}".rstrip() for d, desc in ports) - message = f"{reason} -- available serial ports: {listing}" - else: - message = f"{reason} -- no serial ports detected on this host." - return MonitorError( - "monitor.no-port", - message, - ExitCode.RUNTIME_FAILURE, - {"schemaVersion": DATA_SCHEMA_VERSION, "availablePorts": _ports_data(ports)}, - ) - - -def _run_monitor(port: str | None, baud: int) -> tuple[dict, list[Issue], ExitCode]: - # Frozen (PyInstaller) or an embedded interpreter with no reportable - # `sys.executable`: fall back to a PATH name, mirroring - # `build_cmd._planner_python` -- NOT `sys.executable`, which under a - # PyInstaller freeze IS `tan` itself and would just re-enter this CLI. - using_this_interpreter = not getattr(sys, "frozen", False) and bool(sys.executable) - python = ( - sys.executable - if using_this_interpreter - else _planner_python(str(Path.cwd()), None) - ) - - if using_this_interpreter: - # This precheck only proves the interpreter about to be spawned -- - # THIS one -- has pyserial. It says nothing about a PATH `python` - # resolved via `_planner_python()`, so skip it there; a missing - # pyserial in the child surfaces as the child's own reported failure. - try: - import serial # noqa: F401, PLC0415 (validates pyserial is installed) - except ImportError as err: - raise _pyserial_missing() from err - - if port is None: - raise _refuse_listing_ports("no --port given") - if port not in {device for device, _ in _available_ports()}: - raise _refuse_listing_ports(f"port '{port}' not found") - - print(f"monitor: {port} @ {baud} (Ctrl+] to quit)", file=sys.stderr) - try: - rc = subprocess.run( - [python, "-m", "serial.tools.miniterm", port, str(baud)] - ).returncode - except OSError as err: - raise MonitorError( - "monitor.launch-failed", - f"failed to launch `{python} -m serial.tools.miniterm`: {err}", - ExitCode.RUNTIME_FAILURE, - {"schemaVersion": DATA_SCHEMA_VERSION, "port": port, "baud": baud}, - ) from err - - data = {"schemaVersion": DATA_SCHEMA_VERSION, "port": port, "baud": baud} - if rc != 0: - return ( - data, - [ - Issue( - "monitor.failed", - "error", - f"`tan monitor` exited with code {rc} (see log above).", - ) - ], - ExitCode.RUNTIME_FAILURE, - ) - return data, [], ExitCode.SUCCESS - - -def monitor( - port: str = typer.Option( - None, - "--port", - help="Serial port (COM7, /dev/ttyUSB0, /dev/cu.usbmodem...).", - ), - baud: int = typer.Option( - DEFAULT_BAUD, "--baud", show_default=True, help="Baud rate." - ), - output_format: str = typer.Option( - "text", "--format", metavar="FORMAT", help="Output format: text or json." - ), -) -> None: - """Open a serial console to the board.""" - if output_format not in ("text", "json"): - raise typer.BadParameter( - f"'{output_format}' (choose from 'text', 'json')", param_hint="--format" - ) - json_mode = output_format == "json" - - def finish(data: dict, issues: list[Issue], exit_code: ExitCode) -> None: - if json_mode: - emit( - Envelope( - "monitor", Project(root=None, board_yaml=None), data, issues, exit_code - ) - ) - else: - for issue in issues: - print(f"monitor: {issue.message}", file=sys.stderr) - raise typer.Exit(int(exit_code)) - - try: - data, issues, exit_code = _run_monitor(port, baud) - except MonitorError as err: - finish(err.data, [Issue(err.code, "error", err.message)], err.exit_code) - return - except Exception as err: # noqa: BLE001 -- the envelope IS the error contract - finish( - {"schemaVersion": DATA_SCHEMA_VERSION}, - [ - Issue( - "monitor.internal-failure", - "error", - f"monitor failed unexpectedly: {type(err).__name__}: {err}", - ) - ], - ExitCode.INTERNAL_FAILURE, - ) - return - - finish(data, issues, exit_code) +# SPDX-License-Identifier: Apache-2.0 +"""`tan monitor` -- open a serial console to the attached board. + +Port of `scripts/alp_cli/monitor.py` (73 lines): a thin front door over +pyserial's `miniterm`. Port comes from `--port`; baud from `--baud` (default +115200, the SDK-wide console default). + +There is no safe cross-platform guess for the port itself (COMx vs +`/dev/ttyUSBx` vs `/dev/cu.*`), so when no port is given -- or the requested +one does not exist -- this command lists every serial port pyserial can see +and refuses instead of hanging on a wrong device. + +Board-context port resolution -- filling in `--port` from the current project +instead of asking for it -- is deliberately NOT implemented here, and it is +not simply unstarted (tan-cli#255): the build-plan already carries a +`slices[].debug.console` selector per slice (`build-plan-v1.schema.json`, +issue #610 §4; computed here too, at `tan/planner/buildplan.py::_slice_debug`, +and independently in alp-sdk's own `scripts/alp_orchestrate/buildplan.py`), +resolving to `"uart"` / `"ram"` / `"linux"` / `null`. That is a console +BACKEND CLASS, not a port: it says a slice's console is a UART (as opposed to +a RAM console read over SWD, or a Linux tty), never which host-visible device +that UART shows up as. Nothing in `board.yaml` or the build-plan carries a +VID:PID, serial number, or platform-specific device path for a board's +console UART, so `debug.console == "uart"` still leaves every USB-serial +adapter on the bench indistinguishable to this host OS -- reading it would not +let this command fill in `--port`. Teach this verb to read a real per-board +physical-port fact once metadata carries one; `debug.console` alone is not +that fact. + +**No alp-sdk checkout required, unlike `model`.** The oracle's `monitor.py` +imports nothing from alp-sdk beyond `alp_cli._workspace.python_exe`, itself +just `sys.executable` -- the running interpreter. This port does NOT read +`sys.executable` directly, though: under PyInstaller `sys.executable` IS +`tan` itself, so spawning it would just re-enter this CLI instead of +launching miniterm -- the same reasoning `build_cmd.py`'s `_planner_python` +and `generate_cmd.py` already carry, spelled out there so it need not be +re-argued per call site. This port reuses that same function, a PATH name +(`python`/`python3`) never `sys.executable`, when frozen or when +`sys.executable` is empty (an embedded interpreter can report ""); the +running interpreter is still preferred otherwise, since it is guaranteed to +have `serial` importable already. Either way no SDK root is resolved, so +`tan monitor` no longer requires a resolvable alp-sdk checkout the way the +retired Rust forwarder did (`crates/tan-cli/src/commands/sdk_cli.rs` resolves +one unconditionally for every forward, `monitor` included, purely as an +artifact of sharing one function with `model`/`new-som`/`faultdecode`) -- a +deliberate, documented improvement, not a regression: `monitor` never read +anything an SDK root would supply. + +**Exit code on a failed miniterm run is `RuntimeFailure` (1) regardless of the +child's own exit code** -- mirroring the shipped Rust forwarder +(`sdk_cli::run`'s `s.code().unwrap_or(1)` branch always maps to +`ExitCode::RuntimeFailure`), which is the customer-facing contract today, NOT +the oracle's literal `raise SystemExit(rc)` passthrough of whatever code +miniterm returned. The actual child code still reaches the issue message. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import typer + +from tan.commands.build_cmd import _planner_python +from tan.envelope import Envelope, Issue, Project, emit +from tan.exit_codes import ExitCode + +#: The SDK-wide console default, matching `monitor.py::DEFAULT_BAUD`. +DEFAULT_BAUD = 115200 + +#: `data.schemaVersion` for this command's payload. +DATA_SCHEMA_VERSION = "1" + + +class MonitorError(Exception): + """A refusal whose issue code and exit code are already decided.""" + + def __init__(self, code: str, message: str, exit_code: ExitCode, data: dict) -> None: + super().__init__(message) + self.code = code + self.message = message + self.exit_code = exit_code + self.data = data + + +def _pyserial_missing() -> MonitorError: + """The one spelling of "pyserial is not installed". + + The hint names the EXTRA rather than the bare distribution because that is + the supported way to get it: pyserial is declared in + `[project.optional-dependencies] monitor`, not in `dependencies`. A frozen + `--onefile` build resolves it at BUILD time, so a customer holding a binary + built without the extra cannot pip-install their way out -- hence the second + sentence, which is the only actionable thing to tell them. + """ + return MonitorError( + "monitor.pyserial-missing", + "pyserial is required for `tan monitor`. Install it with " + '`pip install "alp-tan[monitor]"`. A frozen `tan` binary bundles it at ' + "build time, so a binary built without that extra cannot gain it here.", + ExitCode.RUNTIME_FAILURE, + {"schemaVersion": DATA_SCHEMA_VERSION}, + ) + + +def _available_ports() -> list[tuple[str, str]]: + """`[(device, description)]` for every serial port pyserial can see. + + The import is guarded HERE, not only at the caller, because this is the one + choke point every port-listing path routes through -- and because + `_run_monitor`'s precheck is deliberately skipped on a FROZEN build (there + is no `sys.executable` worth validating there). On a `--onefile` binary + built without the `monitor` extra this line is therefore the FIRST place + pyserial is touched, and it is reached IN-PROCESS before any child is + spawned. Left unguarded the ImportError escaped as an unexpected exception + and surfaced as `monitor.internal-failure` at exit 5 -- "tan has a bug" -- + for what is simply an optional dependency the customer never installed. + """ + try: + from serial.tools import list_ports # noqa: PLC0415 (optional at runtime) + except ImportError as err: + raise _pyserial_missing() from err + + return [(p.device, p.description or "") for p in list_ports.comports()] + + +def _ports_data(ports: list[tuple[str, str]]) -> list[dict[str, str]]: + return [{"device": device, "description": description} for device, description in ports] + + +def _refuse_listing_ports(reason: str) -> MonitorError: + """Port of `monitor.py::_die_listing_ports`: the reason plus every serial + port pyserial can see, folded into one issue message so `--format json` + carries the same information the oracle prints line-by-line to stderr.""" + ports = _available_ports() + if ports: + listing = "; ".join(f"{d} {desc}".rstrip() for d, desc in ports) + message = f"{reason} -- available serial ports: {listing}" + else: + message = f"{reason} -- no serial ports detected on this host." + return MonitorError( + "monitor.no-port", + message, + ExitCode.RUNTIME_FAILURE, + {"schemaVersion": DATA_SCHEMA_VERSION, "availablePorts": _ports_data(ports)}, + ) + + +def _run_monitor(port: str | None, baud: int) -> tuple[dict, list[Issue], ExitCode]: + # Frozen (PyInstaller) or an embedded interpreter with no reportable + # `sys.executable`: fall back to a PATH name, mirroring + # `build_cmd._planner_python` -- NOT `sys.executable`, which under a + # PyInstaller freeze IS `tan` itself and would just re-enter this CLI. + using_this_interpreter = not getattr(sys, "frozen", False) and bool(sys.executable) + python = ( + sys.executable + if using_this_interpreter + else _planner_python(str(Path.cwd()), None) + ) + + if using_this_interpreter: + # This precheck only proves the interpreter about to be spawned -- + # THIS one -- has pyserial. It says nothing about a PATH `python` + # resolved via `_planner_python()`, so skip it there; a missing + # pyserial in the child surfaces as the child's own reported failure. + try: + import serial # noqa: F401, PLC0415 (validates pyserial is installed) + except ImportError as err: + raise _pyserial_missing() from err + + if port is None: + raise _refuse_listing_ports("no --port given") + if port not in {device for device, _ in _available_ports()}: + raise _refuse_listing_ports(f"port '{port}' not found") + + print(f"monitor: {port} @ {baud} (Ctrl+] to quit)", file=sys.stderr) + try: + rc = subprocess.run( + [python, "-m", "serial.tools.miniterm", port, str(baud)] + ).returncode + except OSError as err: + raise MonitorError( + "monitor.launch-failed", + f"failed to launch `{python} -m serial.tools.miniterm`: {err}", + ExitCode.RUNTIME_FAILURE, + {"schemaVersion": DATA_SCHEMA_VERSION, "port": port, "baud": baud}, + ) from err + + data = {"schemaVersion": DATA_SCHEMA_VERSION, "port": port, "baud": baud} + if rc != 0: + return ( + data, + [ + Issue( + "monitor.failed", + "error", + f"`tan monitor` exited with code {rc} (see log above).", + ) + ], + ExitCode.RUNTIME_FAILURE, + ) + return data, [], ExitCode.SUCCESS + + +def monitor( + port: str = typer.Option( + None, + "--port", + help="Serial port (COM7, /dev/ttyUSB0, /dev/cu.usbmodem...).", + ), + baud: int = typer.Option( + DEFAULT_BAUD, "--baud", show_default=True, help="Baud rate." + ), + output_format: str = typer.Option( + "text", "--format", metavar="FORMAT", help="Output format: text or json." + ), + project: str = typer.Option(None, "--project", hidden=True), + board_yaml: str = typer.Option(None, "--board-yaml", hidden=True), + sdk_root: str = typer.Option(None, "--sdk-root", hidden=True), + target: str = typer.Option(None, "--target", hidden=True), + all_targets: bool = typer.Option(False, "--all", hidden=True), + verbose: bool = typer.Option(False, "--verbose", hidden=True), + quiet: bool = typer.Option(False, "--quiet", hidden=True), + no_color: bool = typer.Option(False, "--no-color", hidden=True), + non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True), + ci: bool = typer.Option(False, "--ci", hidden=True), +) -> None: + """Open a serial console to the board.""" + # The ten options above are clap's `GlobalArgs` members (`global = true`) + # that the oracle accepts on EVERY verb, `monitor` included, and never + # reads for this one -- confirmed live (`tan.exe monitor --non-interactive + # --ci --target zephyr-conf --all --project . --board-yaml x --sdk-root x + # --port COM7` reaches the identical "port not found" failure a bare + # `tan.exe monitor --port COM7` does). Declared here purely so the argv + # SURFACE matches: `tan monitor --sdk-root --port COM7` exited 2 as + # a Click "No such option" usage error without this, breaking any caller + # (or saved script) forwarding the global set unconditionally -- unlike + # `model`/`new-som`/`faultdecode`, `monitor` never resolves an SDK root at + # all (see the module docstring), so `--project`/`--board-yaml`/ + # `--sdk-root` are genuinely unread here too, not merely deferred. Hidden + # from `--help` because they do nothing. Same port-wide gap as + # `clean_cmd.clean`/`new_som_cmd.new_som`. + del project, board_yaml, sdk_root, target, all_targets + del verbose, quiet, no_color, non_interactive, ci + if output_format not in ("text", "json"): + raise typer.BadParameter( + f"'{output_format}' (choose from 'text', 'json')", param_hint="--format" + ) + json_mode = output_format == "json" + + def finish(data: dict, issues: list[Issue], exit_code: ExitCode) -> None: + if json_mode: + emit( + Envelope( + "monitor", Project(root=None, board_yaml=None), data, issues, exit_code + ) + ) + else: + for issue in issues: + print(f"monitor: {issue.message}", file=sys.stderr) + raise typer.Exit(int(exit_code)) + + try: + data, issues, exit_code = _run_monitor(port, baud) + except MonitorError as err: + finish(err.data, [Issue(err.code, "error", err.message)], err.exit_code) + return + except Exception as err: # noqa: BLE001 -- the envelope IS the error contract + finish( + {"schemaVersion": DATA_SCHEMA_VERSION}, + [ + Issue( + "monitor.internal-failure", + "error", + f"monitor failed unexpectedly: {type(err).__name__}: {err}", + ) + ], + ExitCode.INTERNAL_FAILURE, + ) + return + + finish(data, issues, exit_code) diff --git a/python/tan/commands/new_som_cmd.py b/python/tan/commands/new_som_cmd.py index aaecd8f7..563f7fd8 100644 --- a/python/tan/commands/new_som_cmd.py +++ b/python/tan/commands/new_som_cmd.py @@ -16,14 +16,21 @@ Differences from the alp_cli original, and why: -* **No `--format json`.** The original has no `--format`/`--json` flag, and - neither does the Rust forwarder's own contract for this verb -- unlike - `faultdecode`, `new-som` never gets a synthesised `--json` - (`sdk_cli.rs::build_argv`). This stays a plain interactive/flag-driven - text tool; stdout carries the same human-readable lines the original - wrote there (skeleton validation notes, `Created `, the checklist), - stderr the same error lines, matching `click.echo(..., err=...)` - verbatim rather than folding either into an envelope that never existed. +* **No `--format json` OUTPUT.** The original has no `--format`/`--json` + flag. The Rust forwarder's clap `GlobalArgs` DOES parse `--format` for + this verb too (`global = true` -- confirmed live: `tan.exe new-som + --format json --sdk-root ` reaches the SDK-root-unresolved failure, + not a parse error), but `new-som` never gets a synthesised `--json` + forwarded to the child the way `faultdecode`'s does + (`sdk_cli.rs::build_argv`), so a SUCCESSFUL run's stdout is plain text + either way. This file matches that split: `--format` is accepted (see the + hidden-option block in `new_som`'s signature, tan-cli#254/#256) so the + argv SURFACE agrees with the oracle, but it stays a plain + interactive/flag-driven text tool -- stdout carries the same + human-readable lines the original wrote there (skeleton validation notes, + `Created `, the checklist), stderr the same error lines, matching + `click.echo(..., err=...)` verbatim rather than folding either into an + envelope that never existed. * **`--sdk-root`/`--project` are new, and required.** The original ran FROM WITHIN an alp-sdk checkout (`REPO_ROOT = @@ -145,12 +152,22 @@ def _check_output_root(_ctx: click.Context, _param: click.Parameter, value: str return value -def _fail(message: str) -> None: - """Print an error to stderr and exit 1 -- the flat exit code the alp_cli - original's `_fail` always used (`raise SystemExit(1)`); prefix reworded - from `alp new-som:` to `new-som:` (RFC #837: the binary is `tan`).""" +def _fail(message: str, exit_code: ExitCode = ExitCode.RUNTIME_FAILURE) -> None: + """Print an error to stderr and exit -- 1 by default, the flat exit code + the alp_cli original's `_fail` always used (`raise SystemExit(1)`) for + every validation failure it can raise (bad SKU, unknown board, ...); + prefix reworded from `alp new-som:` to `new-som:` (RFC #837: the binary + is `tan`). `exit_code` overrides this for the one failure this port adds + that the original never had to: the `--sdk-root`/`--project` resolution + preflight below (the original always ran FROM WITHIN a checkout). That + check mirrors the Rust forwarder's own preflight + (`crates/tan-cli/src/commands/sdk_cli.rs::run`), which exits + `ExitCode::ValidationFailure` (2) for it specifically -- confirmed live: + `tan.exe new-som --sdk-root ` exits 2, not 1 (every OTHER new-som + failure, including a bad-exit from the forwarded child, is RuntimeFailure + (1) there too, matching this default).""" typer.echo(f"new-som: {message}", err=True) - raise typer.Exit(int(ExitCode.RUNTIME_FAILURE)) + raise typer.Exit(int(exit_code)) def _yaml_dquote(value: str) -> str: @@ -590,8 +607,32 @@ def new_som( sdk_root: str = typer.Option( None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." ), + board_yaml: str = typer.Option(None, "--board-yaml", hidden=True), + target: str = typer.Option(None, "--target", hidden=True), + all_targets: bool = typer.Option(False, "--all", hidden=True), + output_format: str = typer.Option(None, "--format", hidden=True), + verbose: bool = typer.Option(False, "--verbose", hidden=True), + quiet: bool = typer.Option(False, "--quiet", hidden=True), + no_color: bool = typer.Option(False, "--no-color", hidden=True), + non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True), + ci: bool = typer.Option(False, "--ci", hidden=True), ) -> None: """Scaffold the metadata skeletons for porting a new SoM.""" + # The nine options above are clap's `GlobalArgs` members (`global = true`) + # that the oracle accepts on EVERY verb, `new-som` included, and never + # reads for this one -- declared here purely so the argv SURFACE matches: + # `tan new-som --ci ...` exits the same as the same invocation without + # `--ci` on the oracle; without this, it was a Click "No such option" + # usage error (exit 2) instead. This is a DIFFERENT claim from the + # docstring's "No --format json" bullet above -- confirmed live + # (`tan.exe new-som --format json --sdk-root ` still reaches the + # SDK-root-unresolved failure rather than a parse error): `--format` IS a + # legal flag on the oracle's `new-som`, it just never gets forwarded as a + # synthesised `--json` (unlike `faultdecode`'s), and a SUCCESSFUL run + # stays plain text on this port either way -- see `clean_cmd.clean`'s + # identical fix for the same port-wide gap. + del board_yaml, target, all_targets, output_format + del verbose, quiet, no_color, non_interactive, ci # Mirrors the original's `type=click.Choice(...)` flag-level validation -- # a Click-usage error (exit 2) BEFORE anything else runs, same as the # original raised it during argument parsing itself. Written as an @@ -616,7 +657,7 @@ def new_som( workspace_root = Path.cwd() / project if project else Path.cwd() active = resolve_sdk_tiered(sdk_root, workspace_root) if active.path is None or not Path(active.path).joinpath(*SDK_MARKER).exists(): - _fail(_SDK_ROOT_UNRESOLVED) + _fail(_SDK_ROOT_UNRESOLVED, ExitCode.VALIDATION_FAILURE) return resolved_sdk = Path(active.path) # tan-cli#263 review: this command WRITES metadata skeletons into diff --git a/python/tan/commands/pinmux_cmd.py b/python/tan/commands/pinmux_cmd.py new file mode 100644 index 00000000..72ffc346 --- /dev/null +++ b/python/tan/commands/pinmux_cmd.py @@ -0,0 +1,519 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan pinmux` -- the E1M pinmux capability table (E1M pad -> silicon +function) for a SoM family (tan-cli#257). + +Mirrors `crates/tan-cli/src/commands/pinmux.rs` plus the `tan-core` helpers it +composes (`pinmux::{parse_pinmux_table_checked, pinmux_family_for_sku}`). +Resolves a `metadata/pinmux/.yaml` family stem from an explicit +`--family` or a `--sku` prefix, reads that table out of the resolved SDK root, +and echoes it in the envelope -- the single source the extension/LSP consume +instead of parsing `metadata/pinmux/.yaml` themselves. + +**Fail-soft, deliberately, with two exceptions** -- this paragraph's, and the +refused `--family` tan-cli#359 added below. An unresolved SDK root, an +unknown SKU, no `--sku`/`--family` at all, or a family with no generated table +on disk are each a `warning`-severity issue at exit 0 -- `pinmux` answers "I +don't know" the same way for all of them, never a hard failure. A table that +DOES exist on disk but fails to parse (schema-version skew) or parses to ZERO +pads (`pinmux-capability-v1.schema.json` requires `minItems: 1`, so an empty +table is never a legitimate v1 document -- and, measured against the real +`metadata/pinmux/v2n.yaml` in this checkout, an all-`"TBD"` family genuinely +reaches this today) is NOT fail-soft: `error` severity, +[`tan.exit_codes.ExitCode.VALIDATION_FAILURE`]. + +**No SDK-resolution warning for a broken project pin.** Unlike `presets`/ +`sdk current`, this command never emits `sdk.project-pin-unresolved` -- +measured against the oracle with a `.alp/sdk-path` pointing at a nonexistent +checkout: `pinmux` silently falls through to `pinmux.sdk-root-unresolved` +exactly as it would with no pointer at all. `resolve_project_paths`/ +`resolve_sdk` (from `presets_cmd`, reused here rather than re-derived) already +carry the pin-rejection detail; this module simply never reads it. + +**Row-level fail-soft mirrors `parse_pinmux_table_checked`, not the +un-checked `parse_pinmux_table`**: a pad row missing `e1m_pad`/`e1m_function` +is DROPPED (never an error), and a row's `e1m_pad == "TBD"` sentinel is +dropped too (the source TSV carries no E1M edge pad for that silicon pad -- +`metadata/pinmux/v2n.yaml`'s ENTIRE table is TBD-only at the time of writing, +which is exactly what makes `pinmux.table-empty` a live path, not a +hypothetical one). + +**`--family` is validated, and the resolved table path re-checked -- a +DELIBERATE divergence from the oracle (tan-cli#359).** The oracle builds +`sdk_root.join("metadata").join("pinmux").join(format!("{fam}.yaml"))` with no +check on `fam` at all, and `Path::join`/`pathlib` both DISCARD the accumulated +prefix when the joined component is absolute -- so `--family / +metadata/pinmux/aen` read a table out of a completely different checkout while +the envelope still reported `sdkRoot` as the one it never touched (`..` +components walked out the same way). [`_is_safe_family_stem`] and the +[`resolve_confined`] re-check below close that; see `_resolve`'s own comment for +why BOTH are needed rather than either alone. + +**A pad field is a `String` in the oracle, not a strict `serde_yaml` +struct-typed deserialize -- refuted by running it.** Every `PinmuxPad` field +is `String`, and (measured against the oracle) `String` fields coerce ANY +scalar to its own YAML-spelled text rather than rejecting a wrong-looking +one: `owner: 7` reads back `"7"`, `silicon_pad: true` reads back `"true"`, +both at exit 0 with no issue -- an earlier version of this module treated any +non-`str` scalar there as a hard parse failure, which was simply wrong, not a +documented divergence. Only a genuine compound value (`owner: [a, b]`, +`e1m_pad: {a: b}`) is a real type mismatch no `String` field can absorb, and +that half stayed a document-level `PinmuxParseError` -- one malformed +sequence/mapping field still fails the whole table, not just that row. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import typer + +from tan.commands.presets_cmd import resolve_project_paths, resolve_sdk +from tan.core.fs_confine import PathEscapeError, resolve_confined +from tan.envelope import Envelope, Issue, Project, SdkInfo, emit +from tan.exit_codes import ExitCode + +#: `data.schemaVersion` for this command's payload. +DATA_SCHEMA_VERSION = "1" + +#: The only `schemaVersion` a `metadata/pinmux/.yaml` may declare +#: (`pinmux-capability-v1.schema.json`). +SCHEMA_VERSION = "pinmux-capability-v1" + +#: `sku` prefix -> pinmux family stem (`metadata/pinmux/.yaml`), checked +#: in order -- verbatim from `tan_core::pinmux::pinmux_family_for_sku`. E1M-V2M +#: reuses the base V2N pinout in full (`metadata/e1m_modules/v2n-m1/README.md`) +#: -- there is no separate `v2n-m1.yaml` table, so it maps to `"v2n"` too. +_FAMILY_PREFIX_TABLE = ( + ("E1M-AEN", "aen"), + ("E1M-NX9", "imx93"), + ("E1M-V2N", "v2n"), + ("E1M-V2M", "v2n"), +) + + +def pinmux_family_for_sku(sku: str) -> str | None: + """The pinmux family stem for `sku`'s prefix, or `None` for an + unrecognized SKU.""" + for prefix, stem in _FAMILY_PREFIX_TABLE: + if sku.startswith(prefix): + return stem + return None + + +def _is_safe_family_stem(family: str) -> bool: + """True when `family` is a plain `metadata/pinmux/.yaml` stem: ASCII + letters/digits/`-`/`_`, non-empty (tan-cli#359). + + A CHARSET allowlist rather than a shape blocklist, the same trade + `tan.core.flash_plan.validate_identifier` already makes in this tree for + the same reason: every shape a hand-written blocklist would have to + enumerate carries a character this charset already rejects -- a separator + (`/` AND `\\`, checked in the raw string on EVERY host, because pathlib on + POSIX does not treat `\\` as one, so an `os.sep`-based check would protect + Linux and leave Windows open), a Windows drive or UNC prefix (`:`), a `.` + or `..` component (`.`), a NUL or a newline. Deliberately tighter than + `tan_core::path_guard::is_plain_relative`, which is a check for a plain + RELATIVE PATH: `a/b` passes that and is still not a family stem, and on + POSIX it also accepts `C:\\x` and `..\\..\\x` as ordinary filenames. + + No dot is admitted because no `metadata/pinmux/*.yaml` stem has ever + contained one (`aen`, `imx93`, `v2n`); admitting one to be liberal would + buy nothing and hand back the `.`/`..` component this rejects outright. + """ + return bool(family) and all(c.isascii() and (c.isalnum() or c in "-_") for c in family) + + +@dataclass(frozen=True) +class PinmuxPad: + e1m_pad: str + e1m_function: str + owner: str + silicon_peripheral: str + silicon_pad: str + + def as_dict(self) -> dict[str, str]: + return { + "e1mPad": self.e1m_pad, + "e1mFunction": self.e1m_function, + "owner": self.owner, + "siliconPeripheral": self.silicon_peripheral, + "siliconPad": self.silicon_pad, + } + + +@dataclass(frozen=True) +class PinmuxTable: + family: str + display_name: str | None + pads: list[PinmuxPad] + + +class PinmuxParseError(Exception): + """The pinmux capability document itself did not parse, or its + `schemaVersion` is not `pinmux-capability-v1` -- the two `Err` cases + `parse_pinmux_table_checked` distinguishes from an ordinary fail-soft + dropped row (see the module docstring).""" + + +def _yaml_kind(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "a boolean" + if isinstance(value, (int, float)): + return "a number" + if isinstance(value, str): + return "a string" + if isinstance(value, list): + return "a sequence" + if isinstance(value, dict): + return "a mapping" + return type(value).__name__ + + +def _pad_field(row: dict, field: str) -> str | None: + """`row[field]` coerced to its YAML-spelled string, or `None` when the + key is absent/null. Raises `PinmuxParseError` for a sequence/mapping + value -- the one shape a `String` pad field can never absorb; every + OTHER scalar (bool/int/float, in addition to an actual string) takes its + YAML-spelled text instead, matching the oracle's own `String`-field + coercion (measured: `owner: 7` -> `"7"`, `silicon_pad: true` -> `"true"`, + both at exit 0 -- see the module docstring).""" + value = row.get(field) + if value is None: + return None + if isinstance(value, (list, dict)): + raise PinmuxParseError(f"pads[].{field}: expected a string, got {_yaml_kind(value)}") + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, str): + return value + return str(value) + + +def parse_pinmux_table_checked(text: str) -> PinmuxTable: + """Parse a `pinmux-capability-v1` YAML document. Raises `PinmuxParseError` + for a document that does not parse, is not a mapping, or does not declare + the exact `schemaVersion` this parser accepts. Individual pad rows fail + soft per the module docstring.""" + try: + import yaml # noqa: PLC0415 (optional at runtime, by design) + except ImportError as err: + raise PinmuxParseError( + "this build of tan has no YAML support installed, so the pinmux table cannot " + "be read." + ) from err + try: + raw = yaml.safe_load(text) + except Exception as err: # noqa: BLE001 -- yaml.YAMLError and anything a loader raises + raise PinmuxParseError(f"could not be parsed: {err}") from err + + if not isinstance(raw, dict): + raw = {} + schema_version = raw.get("schemaVersion") + if schema_version != SCHEMA_VERSION: + raise PinmuxParseError( + f"unsupported pinmux capability schemaVersion {schema_version!r} " + f"(expected {SCHEMA_VERSION!r})" + ) + + raw_pads = raw.get("pads") + if raw_pads is not None and not isinstance(raw_pads, list): + raise PinmuxParseError(f"pads: expected a sequence, got {_yaml_kind(raw_pads)}") + + pads: list[PinmuxPad] = [] + for row in raw_pads or []: + if not isinstance(row, dict): + raise PinmuxParseError(f"pads[]: expected a mapping, got {_yaml_kind(row)}") + e1m_pad = _pad_field(row, "e1m_pad") + e1m_function = _pad_field(row, "e1m_function") + if e1m_pad is None or e1m_function is None: + continue # `p.e1m_pad?`/`p.e1m_function?` -- missing key, drop the row + if e1m_pad == "TBD": + continue # sentinel: no E1M edge ball for this silicon pad + pads.append( + PinmuxPad( + e1m_pad=e1m_pad, + e1m_function=e1m_function, + owner=_pad_field(row, "owner") or "", + silicon_peripheral=_pad_field(row, "silicon_peripheral") or "", + silicon_pad=_pad_field(row, "silicon_pad") or "", + ) + ) + + family = raw.get("family") + display_name = raw.get("display_name") + return PinmuxTable( + family=family if isinstance(family, str) else "", + display_name=display_name if isinstance(display_name, str) else None, + pads=pads, + ) + + +_ResolvedSdk = tuple[str, str, str | None] +_ResolveResult = tuple[ + _ResolvedSdk | None, str | None, str | None, list[PinmuxPad], list[Issue], ExitCode +] + + +def _resolve( + sku: str | None, family: str | None, sdk_root: str | None, root: str +) -> _ResolveResult: + """`(sdk, resolved_family, display_name, pads, issues, exit_code)` -- the + whole family/table resolution, isolated from `pinmux()` so the command's + own top-level `try`/`except` can wrap it once (matching `presets_cmd`'s + own catch-all convention: an exception nobody enumerated must still reach + the caller as one coded issue, never a bare traceback with an empty + stdout). + """ + issues: list[Issue] = [] + + # Family resolution: explicit `--family` wins; else map `--sku` by prefix. + # `--family` never even evaluates whether `--sku` maps -- no unknown-sku + # warning fires when `--family` is also given (measured against the + # oracle: `--sku E1M-BOGUS --family v2n` reports family "v2n", no + # `pinmux.unknown-sku` issue). + resolved_family: str | None + if family is not None: + resolved_family = family + elif sku is not None: + resolved_family = pinmux_family_for_sku(sku) + if resolved_family is None: + issues.append( + Issue( + "pinmux.unknown-sku", + "warning", + f"SKU '{sku}' maps to no known pinmux family.", + ) + ) + else: + resolved_family = None + issues.append( + Issue("pinmux.no-target", "warning", "Provide --sku or --family .") + ) + + exit_code = ExitCode.SUCCESS + sdk = resolve_sdk(sdk_root, root) + display_name: str | None = None + pads: list[PinmuxPad] = [] + + # tan-cli#359. `--family` is caller-controlled and reaches this join + # unvalidated on the oracle: an ABSOLUTE value discards the SDK prefix + # entirely (`Path("/sdk-a") / "/sdk-b/metadata/pinmux/aen"` IS + # `/sdk-b/...`, same as Rust's `Path::join`) and `..` walks out of it, so + # the envelope reported `sdkRoot` = the checkout it never read. Checked + # here, at the ONE place a family becomes a path, so the `--sku` route is + # covered by construction too -- and BEFORE `sdk`/`resolved_family` are + # even paired, so a rejected family never reaches the read. + # + # TWO INDEPENDENT checks, because each has a hole the other covers: the + # stem charset cannot see a SYMLINK planted inside `metadata/pinmux/` + # (`aen.yaml` -> elsewhere is a perfectly plain stem), and a containment + # re-check alone would wave through a `../pinmux/aen`-shaped family that + # merely happens to land back inside. Exactly one coded issue either way; + # nothing else can have been appended yet at the first check (`--family` + # short-circuits the `--sku` lookup, and `pinmux.no-target` only fires + # when `resolved_family is None`). + if resolved_family is not None and not _is_safe_family_stem(resolved_family): + issues.append( + Issue( + "pinmux.family-invalid", + "error", + f"Pinmux family '{resolved_family}' is not a plain family stem " + "(ASCII letters, digits, '-' and '_' only) -- refusing to read a " + "table from outside /metadata/pinmux.", + ) + ) + return sdk, resolved_family, None, [], issues, ExitCode.VALIDATION_FAILURE + + if sdk is not None and resolved_family is not None: + table_dir = Path(sdk[0]) / "metadata" / "pinmux" + try: + # `resolve_confined` resolves BOTH sides and compares components, + # so it is correct where a `str.startswith` prefix test is not + # (Windows case folding, `C:\proj` vs `C:\project2`) -- the same + # shared guard `init`/`generate`/`scaffold` use, not a fourth + # hand-rolled copy of the predicate. `OSError`/`ValueError` are + # caught per its own docstring: a path shape the host rejects + # outright (a Windows device-namespace path, one embedding a NUL) + # raises there and is a refusal just the same. + table_path = resolve_confined(table_dir, table_dir / f"{resolved_family}.yaml") + except (PathEscapeError, OSError, ValueError): + issues.append( + Issue( + "pinmux.family-invalid", + "error", + f"Pinmux capability table for family '{resolved_family}' resolves " + "outside /metadata/pinmux -- refusing to read it.", + ) + ) + return sdk, resolved_family, None, [], issues, ExitCode.VALIDATION_FAILURE + try: + text = table_path.read_text(encoding="utf-8") + except OSError: + issues.append( + Issue( + "pinmux.table-not-found", + "warning", + f"No pinmux capability table for family '{resolved_family}' " + f"(metadata/pinmux/{resolved_family}.yaml).", + ) + ) + else: + try: + table = parse_pinmux_table_checked(text) + except PinmuxParseError as err: + issues.append( + Issue( + "pinmux.schema-version-unsupported", + "error", + f"Pinmux capability table for family '{resolved_family}' failed to " + f"parse (metadata/pinmux/{resolved_family}.yaml): {err}", + ) + ) + exit_code = ExitCode.VALIDATION_FAILURE + else: + display_name = table.display_name + pads = table.pads + if not pads: + # `pinmux-capability-v1.schema.json` requires `minItems: 1`: + # a successful parse of a real v1 table is never + # legitimately empty. + issues.append( + Issue( + "pinmux.table-empty", + "error", + f"Pinmux capability table for family '{resolved_family}' parsed " + f"with zero pads (metadata/pinmux/{resolved_family}.yaml).", + ) + ) + exit_code = ExitCode.VALIDATION_FAILURE + elif sdk is None: + issues.append( + Issue( + "pinmux.sdk-root-unresolved", + "warning", + "alp-sdk root is unresolved; cannot read the pinmux table.", + ) + ) + + return sdk, resolved_family, display_name, pads, issues, exit_code + + +def pinmux( + ctx: typer.Context, + project: str = typer.Option( + None, "--project", metavar="PATH", help="Project root (defaults to current directory)." + ), + sku: str = typer.Option( + None, + "--sku", + metavar="SKU", + help="SoM SKU to resolve the pinmux family from (e.g. `E1M-AEN701`).", + ), + board_yaml: str = typer.Option( + None, + "--board-yaml", + metavar="PATH", + help="Explicit board.yaml path (overrides project resolution).", + ), + family: str = typer.Option( + None, + "--family", + metavar="FAMILY", + help="Pinmux family stem directly (e.g. `aen`, `v2n`); overrides `--sku`.", + ), + sdk_root: str = typer.Option( + None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." + ), + target: str = typer.Option( # accepted, not read + None, + "--target", + metavar="EMIT", + help="Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf).", + ), + all_targets: bool = typer.Option( # accepted, not read + False, "--all", help="Run command against all relevant targets." + ), + output_format: str = typer.Option( + None, "--format", metavar="FORMAT", help="Output format: text or json." + ), + verbose: bool = typer.Option( # accepted, not read + False, "--verbose", help="Emit additional diagnostic detail." + ), + quiet: bool = typer.Option( # accepted, not read; pinmux's text line is unconditional + False, "--quiet", help="Suppress non-essential output." + ), + no_color: bool = typer.Option( # accepted, not read; pinmux emits no ANSI color + False, "--no-color", help="Disable ANSI color in text output." + ), + non_interactive: bool = typer.Option( # accepted, not read; pinmux never prompts + False, "--non-interactive", help="Never prompt." + ), + ci: bool = typer.Option( # accepted, not read + False, "--ci", help="CI mode: implies non-interactive and disables color." + ), +) -> None: + """Show the E1M pinmux capability table (E1M pad -> silicon function) for + a SoM family. + + `--target`/`--all`/`--verbose`/`--quiet`/`--no-color`/`--non-interactive`/ + `--ci` are declared, not consumed: `pinmux` reads only `--sku`/`--family` + plus the resolved SDK root (`crates/tan-cli/src/commands/pinmux.rs` never + touches `GlobalArgs::target`/`all`/`verbose`/`quiet`), but the oracle's + clap `GlobalArgs` are `global = true`, so every verb accepts all of them. + """ + del target, all_targets, verbose, quiet, no_color, non_interactive, ci + resolved_format = ( + output_format if output_format is not None else (ctx.obj or {}).get("format") or "text" + ) + if resolved_format not in ("text", "json"): + raise typer.BadParameter( + f"'{resolved_format}' (choose from 'text', 'json')", param_hint="--format" + ) + json_mode = resolved_format == "json" + + root, board_path = resolve_project_paths(project, board_yaml) + try: + sdk, resolved_family, display_name, pads, issues, exit_code = _resolve( + sku, family, sdk_root, root + ) + except Exception as err: # noqa: BLE001 -- the envelope IS the error contract + sdk, resolved_family, display_name, pads = None, family, None, [] + issues = [ + Issue( + "pinmux.internal-failure", + "error", + f"pinmux failed unexpectedly: {err.__class__.__name__}: {err}", + ) + ] + exit_code = ExitCode.INTERNAL_FAILURE + + data: dict[str, Any] = { + "schemaVersion": DATA_SCHEMA_VERSION, + "sdkRoot": sdk[0] if sdk is not None else None, + } + if sku is not None: + data["sku"] = sku + data["family"] = resolved_family + if display_name is not None: + data["displayName"] = display_name + data["pads"] = [p.as_dict() for p in pads] + + if json_mode: + emit( + Envelope( + "pinmux", + Project.resolved(root, board_path), + data, + issues, + exit_code, + sdk=SdkInfo(sdk[0], sdk[1]) if sdk is not None else None, + ) + ) + else: + stream = typer.get_text_stream("stderr") + stream.write(f"pinmux: family={resolved_family or '-'} pads={len(pads)}\n") + raise typer.Exit(int(exit_code)) diff --git a/python/tan/commands/presets_cmd.py b/python/tan/commands/presets_cmd.py index 4676b0f3..95239bd6 100644 --- a/python/tan/commands/presets_cmd.py +++ b/python/tan/commands/presets_cmd.py @@ -64,6 +64,7 @@ import typer from tan.commands.sdk_cmd import SDK_MARKER, project_pin_issue, resolve_sdk_tiered +from tan.core.global_flags import accept_global_flags from tan.envelope import Envelope, Issue, Project, SdkInfo, emit from tan.exit_codes import ExitCode @@ -638,3 +639,10 @@ def presets( for line in render_presets_text(skus, board_libraries, verbose): print(line, file=sys.stderr) raise typer.Exit(int(exit_code)) + + +# tan-cli#261: adds the six oracle `GlobalArgs` flags this command was still +# missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--quiet`/ +# `--target`) on top of `--board-yaml`/`--verbose`, already declared and read +# above; see `tan.core.global_flags`. +presets = accept_global_flags(presets) diff --git a/python/tan/commands/renode_cmd.py b/python/tan/commands/renode_cmd.py index 9c37e673..e189e594 100644 --- a/python/tan/commands/renode_cmd.py +++ b/python/tan/commands/renode_cmd.py @@ -13,30 +13,45 @@ (https://renode.io) -- never a traceback, never a silent `ok: true`. Mirrors `flash_cmd.py`'s own refusal shape for a missing hardware tool. -**SCOPE: this file is the PLAIN (non-`--sim-mode`) headless smoke only.** -`--sim-mode` -- the studio hardware-simulator gateway that serves a control + -UART socket pair for `alp-sdk-vscode`'s `RealRenodeAdapter` -- is a -substantial separate subsystem in the oracle (`crates/tan-cli/src/commands/ -renode/sim.rs` + `monitor.rs`, ~1100 lines, plus its own pure half in -`crates/tan-core/src/renode/sim.rs`). It is deliberately NOT ported here: the -flag is simply not declared, so `tan renode --sim-mode` is a Click usage error -(exit 2) rather than a half-working or silently-wrong gateway. Refusing at the -parser is the honest shape for an unported subsystem -- an ACCEPTED flag that -quietly did nothing would be worse, because the customer would believe the -gateway came up. - -(An earlier draft of this paragraph justified the cut by claiming -`doctor_cmd.py` likewise does not declare `tan doctor`'s `--build`. That was -false -- `doctor_cmd.py:894` declares `--build` -- and the claim is removed -rather than corrected, because the cut stands on its own reasoning above and -did not need a precedent.) -Porting `--sim-mode` is its own bounded unit of work. This is a DELIBERATE, -NAMED gap, not an oversight: the follow-up unit is "port `--sim-mode`" -- -`crates/tan-cli/src/commands/renode/sim.rs` + `monitor.rs` (the socket -gateway) and `crates/tan-core/src/renode/sim.rs` (its pure half). +**This file now covers BOTH the plain headless smoke and `--sim-mode`, the +studio hardware-simulator gateway** that serves a control + UART socket pair +for `alp-sdk-vscode`'s `RealRenodeAdapter` (`tan-cli#77`). The plain path's +pre-flight *decisions* stay pure in `tan.core.renode_plan`; `--sim-mode`'s own +pure half (the `sim-descriptor.json` document, the generated boot script, the +control-line protocol, the monitor-line classifier) is +`tan.core.renode_sim`. This module resolves paths, probes PATH for the +`renode` binary, and owns every bit of IO: spawning + teeing the plain smoke, +and -- for `--sim-mode` -- binding the two ephemeral listeners, writing the +descriptor + boot script, spawning Renode with its monitor on a pipe, and +serving both sockets. + +`--sim-mode` is a faithful port of `crates/tan-cli/src/commands/renode/ +{sim,monitor}.rs` + `crates/tan-core/src/renode/sim.rs` (landed as +`5152fd4 feat(renode): implement the --sim-mode socket contract (#77) (#96)`), +itself ported from the retired Python `west alp-renode --sim-mode` +(`scripts/west_commands/alp_renode.py`, deleted in `alp-sdk@df312cec` under +ADR-0020 Phase 4). Every wire decision below was diff-verified against the +shipped `tan.exe` oracle driven live through the full pipeline -- every +pre-flight refusal code, the generated `sim-descriptor.json` and +`.sim-boot.resc` byte-for-byte, a real control-socket round trip, the +`renode.cpu-halted` latch, and `renode.sim-exited-early` -- not inferred from +source alone. + +SCOPE (`tan-cli#77`, socket half): ports + descriptor + readiness marker + the +three-verb control protocol. DEFERRED to a follow-up on the same issue: the +`ram_console_buf` RAM-ring -> UART-socket streamer, the wired-UART console +path, and the per-SKU sim profiles behind the descriptor's +`framebuffers`/`peripherals` -- which stay `[]` here, with every run carrying +`renode.sim-profile-deferred` as a warning issue so an empty descriptor is +never mistaken for success. See `tan.core.renode_sim`'s module docstring for +the fuller account, including why this issue's own "no reference +implementation exists" framing does not hold: a Rust port of exactly this +contract already exists (frozen, but readable and CI-verified) and this file +is a faithful Python port of it, not a fresh re-derivation from issue prose. Divergences from the Rust oracle worth flagging, both verified against the -shipped `tan.exe` rather than inferred from source: +shipped `tan.exe` rather than inferred from source. First, the ones shared +with (already documented for) the plain smoke: * `data.repl`/`data.resc`/`data.elf`/`data.logPath` and `project.root` are all reported in the HOST's NATIVE path style (backslashes on Windows, unconverted), NOT forward-slash-normalised -- unlike most other `tan` @@ -65,11 +80,41 @@ reports the unnormalised `.../renodefx/./alp-sdk`. Only the `--project .` discovery path is affected -- `--sdk-root` itself is reported raw (see above). + +`--sim-mode`-specific behaviour, pinned by driving the oracle live rather +than only reading `sim.rs`/`monitor.rs`: + * `data.logPath` starts as the PLAIN smoke's own default + (`/renode.log`, computed before the sim/plain branch even + though sim mode never uses a build root) and only becomes the + sim-specific default (`/renode-sim.log`) once the run gets + far enough to resolve it -- AFTER `repl`/`elf`/`descriptor`/the socket + ports are all already known. A pre-flight failure up to and including + `renode.binary-missing` therefore reports the PLAIN default in + `data.logPath`, not the sim one -- verified: `tan renode --sim-mode + --image-bundle --sdk-root --board ` with `renode` + missing from PATH reports `logPath` as `/build/renode.log`. + * The readiness marker (`tan.core.renode_sim.ready_marker`) and, in text + mode only, five human-readable header lines (`sku`/elf, `descriptor`, + `control`, `uart`, and the `renode.sim-profile-deferred` warning's own + text) are printed DIRECTLY to a real stream the moment they are known -- + stdout in text mode, and (for the readiness marker only; the header + lines are text-mode-only) stderr in JSON mode -- rather than being + buffered into the text/issues the envelope machinery below prints once + at the very end. This is because `--sim-mode` blocks for `--timeout` + seconds serving sockets: an operator or a studio launcher needs the + descriptor and ports the instant they exist, not once the whole run + finishes. Verified with the streams captured separately. + * `--expect` is accepted (for global-flag parity, like `--image-bundle` on + the plain smoke) but reported back via an INFO issue + (`renode.expect-ignored`) rather than acted on: sim mode routes the + console to the UART socket, not to a scannable text stream. """ from __future__ import annotations +import json import os import queue +import socket import subprocess import sys import threading @@ -83,6 +128,7 @@ from tan.commands.sdk_cmd import project_pin_issue from tan.commands.build_output import ManifestInvalid, ManifestUnavailable, load_manifest from tan.commands.doctor_cmd import on_path +from tan.core.global_flags import accept_global_flags from tan.core.renode_plan import ( RenodeError, build_renode_argv, @@ -94,6 +140,16 @@ select_sku, zephyr_elf_from_manifest, ) +from tan.core.renode_sim import ( + MonitorLine, + build_sim_descriptor, + build_sim_renode_argv, + build_sim_resc_text, + classify_monitor_line, + dispatch_control_line, + ready_marker, + sim_profile_deferred_message, +) from tan.envelope import Envelope, Issue, Project, SdkInfo, emit from tan.exit_codes import ExitCode @@ -171,9 +227,9 @@ def _data(**overrides: Any) -> dict[str, Any]: "expect": None, "expectFound": False, "renodeArgv": [], - # `--sim-mode` only (not ported here -- see the module docstring): - # always present and empty/zero on the plain smoke, matching the - # oracle's own `RenodeReport::default()` fields. + # `--sim-mode` only: always present and empty/zero on the plain + # smoke (and on a sim failure before each is resolved), matching + # the oracle's own `RenodeReport::default()` fields. "descriptor": "", "controlPort": 0, "uartPort": 0, @@ -349,6 +405,374 @@ def _best_effort_vtor(elf_path: str) -> int | None: return elf_vector_table_base(data) +# ── --sim-mode: the monitor bridge ────────────────────────────────────────── + +#: Per-command deadline. A wedged-but-alive Renode must not strand a client. +_COMMAND_TIMEOUT_S = 15.0 +#: Total budget for the boot drain, split across `_DRAIN_ATTEMPTS`. +_DRAIN_TIMEOUT_S = 90.0 +#: On a loaded runner the pinned Renode's monitor can be slow to first +#: respond, and a single lost sync would strand the whole session. +_DRAIN_ATTEMPTS = 3 +#: Every `RenodeMonitor` failure path reports this once broken. Reused for a +#: broken-latch race: a client thread that failed mid-command may have left +#: unread lines in the queue, so the next command could otherwise capture +#: *its* output -- indistinguishable from a correct reply, which is the one +#: outcome worth refusing outright. +_MONITOR_UNUSABLE = "Renode monitor is unusable after an earlier failure." +#: How long teardown waits for Renode to act on `quit` before killing it. +#: This is a teardown, not a graceful-shutdown protocol -- the process is +#: going away either way; one second is enough for the emulation to close +#: its sockets and flush its log. +_QUIT_GRACE_S = 1.0 + + +class RenodeMonitor: + """Drives Renode's monitor over the child's stdin/stdout for `tan renode + --sim-mode`. Plumbing ONLY -- every decision about what a monitor line + means is `tan.core.renode_sim.classify_monitor_line`, unit-tested there; + this class owns the pipes, the reader thread and the deadline. + + Port of `crates/tan-cli/src/commands/renode/monitor.rs`'s + `RenodeMonitor`. `command` writes the line plus an `echo ""` + marker and drains stdout until the bare sentinel comes back; the pump + thread + queue-with-timeout is what lets the per-command deadline + actually fire, rather than a blocking read hanging forever on a wedged + Renode. `command`/`quit`/`drain_boot` all serialise on `self._lock`, + matching the Rust `Mutex>` -- a client sending two + commands concurrently must not interleave their sentinels. + """ + + def __init__(self, stdin: Any, stdout: Any) -> None: + self._stdin = stdin + self._lock = threading.Lock() + self._seq = 0 + self._broken = False + self._cpu_halted_flag = False + self._lines: "queue.Queue[object]" = queue.Queue() + raw: "queue.Queue[object]" = queue.Queue() + threading.Thread(target=_pump_lossy_lines, args=(stdout, raw), daemon=True).start() + threading.Thread(target=self._forward, args=(raw,), daemon=True).start() + + def _forward(self, raw: "queue.Queue[object]") -> None: + """Two hops: the shared lossy-line pump, then this forwarder, which + inspects every line for the halt marker (issue #64) before handing + it on -- this is what makes the latch window-independent, catching a + `CPU was halted` line that lands between two client commands (or + after the last one), which belongs to no command's collection + window at all.""" + while True: + item = raw.get() + if item is _EOF: + self._lines.put(_EOF) + return + line = item # type: ignore[assignment] + if renode_cpu_halted(line): + self._cpu_halted_flag = True + self._lines.put(line) + + def cpu_halted(self) -> bool: + """Whether Renode ever reported the CPU halted, wherever that line + landed. Read at teardown: the halt does not fail the command it + happens to interleave with, it fails the RUN.""" + return self._cpu_halted_flag + + def command(self, cmd: str) -> str: + """Run one monitor command and return its captured output. Raises + `RuntimeError` (message = the failure reason) on a write failure, + timeout, EOF, or a monitor-reported `[ERROR]` for this command.""" + return self._command_within(cmd, _COMMAND_TIMEOUT_S) + + def _command_within(self, cmd: str, timeout_s: float) -> str: + with self._lock: + if self._broken: + raise RuntimeError(_MONITOR_UNUSABLE) + self._seq += 1 + sentinel = f"__ALP_SIM_DONE_{self._seq}__" + try: + # The marker is QUOTED on purpose: Renode >= 1.16 reads a + # bare `echo TOKEN` as an element lookup ("No such emulation + # element"). + self._stdin.write(f"{cmd}\n".encode()) + self._stdin.write(f'echo "{sentinel}"\n'.encode()) + self._stdin.flush() + except (OSError, ValueError) as err: + self._broken = True + raise RuntimeError( + f"Renode monitor write failed for {_rust_debug_str(cmd)}: {err}" + ) from err + + deadline = time.monotonic() + timeout_s + out: list[str] = [] + errors: list[str] = [] + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + self._broken = True + raise RuntimeError( + f"timed out after {int(timeout_s)}s awaiting Renode " + f"response to {_rust_debug_str(cmd)}." + ) + try: + item = self._lines.get(timeout=remaining) + except queue.Empty: + self._broken = True + raise RuntimeError( + f"timed out after {int(timeout_s)}s awaiting Renode " + f"response to {_rust_debug_str(cmd)}." + ) + if item is _EOF: + self._broken = True + raise RuntimeError( + f"Renode monitor closed while awaiting response to " + f"{_rust_debug_str(cmd)}." + ) + line = item # type: ignore[assignment] + kind = classify_monitor_line(line, sentinel, cmd) + if kind is MonitorLine.DONE: + # Reaching the sentinel with errors collected does NOT + # latch `broken`: the monitor itself is fine, this one + # command failed. + if errors: + raise RuntimeError( + f"Renode reported an error for {_rust_debug_str(cmd)}: " + + " | ".join(errors) + ) + return "\n".join(out) + if kind is MonitorLine.ERROR: + errors.append(line.strip()) + elif kind is MonitorLine.IGNORE: + pass + else: + out.append(line) + + def drain_boot(self) -> None: + """Swallow the boot-time monitor output so the first real client + command gets a clean reply. Retried: each attempt sends a fresh + `version` with its own sentinel, which re-syncs, and clears the + `broken` latch the previous attempt's timeout set. Raises + `RuntimeError` after `_DRAIN_ATTEMPTS` failures.""" + per = max(10.0, _DRAIN_TIMEOUT_S / max(_DRAIN_ATTEMPTS, 1)) + last = f"drain_boot failed after {_DRAIN_ATTEMPTS} attempts" + for _ in range(_DRAIN_ATTEMPTS): + with self._lock: + self._broken = False + try: + self._command_within("version", per) + return + except RuntimeError as err: + last = str(err) + raise RuntimeError(last) + + def quit(self) -> None: + """Best-effort `quit` on teardown. This only ASKS; whether Renode + gets time to shut its emulation down depends on the caller polling + for the exit afterwards (`_teardown_sim` does, briefly).""" + with self._lock: + try: + self._stdin.write(b"quit\n") + self._stdin.flush() + except (OSError, ValueError): + pass + + +def _bind_sim_listeners() -> tuple[socket.socket, socket.socket, int, int]: + """Bind the control + UART listeners on ephemeral `127.0.0.1` ports and + read the assigned port numbers back. Both stay held (LISTENING), so the + ports cannot be taken from under us and are distinct by construction. + Returns `(control, uart, control_port, uart_port)`. + + BIND BEFORE ADVERTISING is the whole point: by the time a caller writes + `sim-descriptor.json` naming these ports, both listeners are already + accepting, so a studio client that reads the descriptor and connects at + once can never race into an ECONNREFUSED -- the kernel backlogs the + connection pre-accept. + """ + ctrl = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + ctrl.bind(("127.0.0.1", 0)) + ctrl.listen() + uart = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + uart.bind(("127.0.0.1", 0)) + uart.listen() + except OSError: + uart.close() + raise + except OSError: + ctrl.close() + raise + return ctrl, uart, ctrl.getsockname()[1], uart.getsockname()[1] + + +def _close_quietly(*socks: socket.socket) -> None: + for s in socks: + try: + s.close() + except OSError: + pass + + +def _serve_control(listener: socket.socket, monitor: RenodeMonitor) -> None: + """Accept control clients forever, each on its own thread. The listener + is already bound + listening by the time the descriptor advertises its + port.""" + while True: + try: + conn, _addr = listener.accept() + except OSError: + return + threading.Thread( + target=_handle_control_client, args=(conn, monitor), daemon=True + ).start() + + +def _handle_control_client(conn: socket.socket, monitor: RenodeMonitor) -> None: + """One control client: line-oriented, ONE request line -> ONE reply + line, until the peer closes. A bad line never kills the connection -- + `dispatch_control_line` answers it with `ERR ` so the framing + invariant holds for the rest of the session. A blank line is skipped + without a reply, verbatim from the retired Python.""" + try: + reader = conn.makefile("rb") + while True: + raw = reader.readline() + if not raw: + return # peer closed, or a real IO error + # Lossy, not strict: a stray non-UTF-8 byte must not drop the + # session. + line = raw.decode("utf-8", errors="replace").strip() + if not line: + continue + reply = dispatch_control_line(line, monitor.command) + try: + conn.sendall(f"{reply}\n".encode()) + except OSError: + return + except OSError: + return + finally: + _close_quietly(conn) + + +def _serve_uart_silent(listener: socket.socket) -> None: + """Accept UART clients and hold each connection open, streaming + nothing. + + This is the socket half of a channel whose CONTENT is deferred + (`tan-cli#77`): the `ram_console_buf` RAM-ring streamer that fills it is + a follow-up. Accepting-and-silent is exactly what the retired Python did + when an image carried no `ram_console_buf` symbol, so a studio client's + serial view connects successfully and simply stays empty rather than + failing to connect.""" + while True: + try: + conn, _addr = listener.accept() + except OSError: + return + threading.Thread(target=_hold_uart_client, args=(conn,), daemon=True).start() + + +def _hold_uart_client(conn: socket.socket) -> None: + """Output-only to studio; read solely to detect the close and reap.""" + try: + while True: + data = conn.recv(256) + if not data: + return + except OSError: + return + finally: + _close_quietly(conn) + + +def _spawn_renode_sim(argv: list[str], log_path: str) -> subprocess.Popen: + """Spawn headless Renode with stdio wired for the monitor bridge: stdin + + stdout are pipes the bridge drives, stderr goes straight to the log + file (verbatim from the retired Python's `stderr=logf`).""" + parent = os.path.dirname(log_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(log_path, "wb") as logf: + return subprocess.Popen( + argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=logf + ) + + +def _teardown_sim(monitor: RenodeMonitor, proc: subprocess.Popen) -> None: + """Ask Renode to `quit`, give it `_QUIT_GRACE_S` to actually do it, then + make sure it is gone. `quit` on its own is only a REQUEST: killing the + child microseconds after the flush would leave the emulation no time to + close its sockets or flush its log.""" + monitor.quit() + deadline = time.monotonic() + _QUIT_GRACE_S + while time.monotonic() < deadline: + if proc.poll() is not None: + return # quit worked; poll() already reaped it + time.sleep(0.025) + try: + proc.kill() + except OSError: + pass + try: + proc.wait() + except OSError: + pass + + +def _announce_ready(json_mode: bool, timeout: int) -> None: + """Print the readiness marker. The LINE is + `tan.core.renode_sim.ready_marker` -- it carries the consumer's `ready + (timeout` poll token and is pinned by a test there, since a reword + strands every consumer. The only decision left here is the STREAM: + stdout in text mode (where the retired Python printed it), stderr in + JSON mode, where stdout carries the single Envelope line and nothing + else. A consumer teeing only stdout to a log file therefore will not see + it in JSON mode -- poll the merged output, or poll for + `sim-descriptor.json` and the port it names.""" + line = ready_marker(timeout) + if json_mode: + print(line, file=sys.stderr) + sys.stderr.flush() + else: + print(line) + sys.stdout.flush() + + +def _resolve_bundle_elf(bundle_dir: str, manifest: Any, core: str | None) -> str: + """Resolve the firmware ELF inside a pre-built `--image-bundle` dir: the + bundle's `system-manifest.yaml` (reusing the slice resolver) -> + `/zephyr/zephyr.elf` -> the single `*.elf` in the bundle. Raises + `RenodeError` on every failure -- the caller maps all of them to + `renode.elf-missing`, matching the oracle's `resolve_bundle_elf`.""" + if manifest is not None: + return zephyr_elf_from_manifest(manifest, bundle_dir, core) + direct = os.path.join(bundle_dir, "zephyr", "zephyr.elf") + if os.path.isfile(direct): + return direct + try: + names = os.listdir(bundle_dir) + except OSError as err: + raise RenodeError(f"could not read --image-bundle {bundle_dir}: {err}") from err + elfs = sorted( + name + for name in names + if name.endswith(".elf") and os.path.isfile(os.path.join(bundle_dir, name)) + ) + if len(elfs) == 1: + return os.path.join(bundle_dir, elfs[0]) + if not elfs: + raise RenodeError( + f"no firmware ELF in --image-bundle {bundle_dir} (looked for " + "system-manifest.yaml, zephyr/zephyr.elf, *.elf)." + ) + names_repr = "[" + ", ".join(_rust_debug_str(e) for e in elfs) + "]" + raise RenodeError( + f"multiple *.elf in --image-bundle {bundle_dir} ({names_repr}); " + "can't pick one automatically." + ) + + # ── the command ───────────────────────────────────────────────────────────── @@ -365,6 +789,7 @@ def _run( expect: str | None, json_mode: bool, cwd: str, + sim_mode_arg: bool = False, ) -> tuple[ExitCode, dict[str, Any], list[Issue], list[str], SdkInfo | None, Project]: """Everything between argument parsing and the envelope. Returns `(exit_code, data, issues, text_lines, sdk, project)`.""" @@ -390,6 +815,29 @@ def data(**overrides: Any) -> dict[str, Any]: def fail(code: str, message: str, exit_code: ExitCode = ExitCode.RUNTIME_FAILURE): return exit_code, data(), [_issue(code, "error", message)], [f"renode: {message}"], None, project + # `--sim-mode` branches FIRST, resolving everything from `--image-bundle` + # instead of the build root below -- mirrors the oracle's `mod.rs::run`, + # which constructs its `RenodeReport` with this same PLAIN-mode + # `log_path` default before handing off to `sim::run`, so a pre-flight + # sim failure up to and including `renode.binary-missing` still reports + # THIS `log_path`, not the sim-specific one (see the module docstring). + if sim_mode_arg: + return _run_sim( + app_path=app_path, + image_bundle_arg=image_bundle_arg, + board_arg=board_arg, + core_arg=core_arg, + sdk_root_arg=sdk_root_arg, + project_arg=project_arg, + log_arg=log_arg, + timeout=timeout, + expect=expect, + json_mode=json_mode, + cwd=cwd, + project=project, + base_log_path=log_path, + ) + # SDK-root guard: `cli_workspace_root(g)` is `cwd` joined with `--project` # (the GLOBAL flag) -- NOT `app_path`. See the module docstring for why # these two deliberately diverge in the oracle. @@ -547,6 +995,278 @@ def fail_sdk(code: str, message: str, exit_code: ExitCode = ExitCode.RUNTIME_FAI ) +def _run_sim( + *, + app_path: str, + image_bundle_arg: str | None, + board_arg: str | None, + core_arg: str | None, + sdk_root_arg: str | None, + project_arg: str | None, + log_arg: str | None, + timeout: int, + expect: str | None, + json_mode: bool, + cwd: str, + project: Project, + base_log_path: str, +) -> tuple[ExitCode, dict[str, Any], list[Issue], list[str], SdkInfo | None, Project]: + """`tan renode --sim-mode`: the studio hardware-simulator gateway. Port + of `crates/tan-cli/src/commands/renode/sim.rs::run`. Returns the same + 6-tuple shape `_run` does; `base_log_path` is the PLAIN smoke's own + `log_path` default, reported in `data.logPath` until this function + resolves its own sim-specific default further down (see the module + docstring).""" + # `logPath` starts as the PLAIN smoke's default and lives in `known` (not + # a hardcoded `data()` kwarg) precisely so the later `known["logPath"] = + # log_path` overwrite below can replace it without a duplicate-keyword + # collision against `**known`. + known: dict[str, Any] = {"logPath": base_log_path} + + def data(**overrides: Any) -> dict[str, Any]: + return _data(timeout=timeout, expect=expect, **known, **overrides) + + def fail(code: str, message: str, exit_code: ExitCode = ExitCode.RUNTIME_FAILURE): + return exit_code, data(), [_issue(code, "error", message)], [f"renode: {message}"], None, project + + if image_bundle_arg is None: + return fail("renode.sim-bundle-required", "--sim-mode requires --image-bundle .") + bundle_dir = _normalize_join(cwd, image_bundle_arg) + if not os.path.isdir(bundle_dir): + return fail( + "renode.sim-bundle-missing", f"--image-bundle {bundle_dir} is not a directory." + ) + + # SDK-root guard: the SAME wide ladder + workspace root the plain smoke + # uses -- the oracle's `sim::run` calls the identical `resolve_sdk_root` + # the plain `mod.rs::run` does, with no sim-specific branching. + workspace_root = os.path.join(cwd, project_arg) if project_arg else cwd + sdk_root, sdk_tier, sdk_broken_pin = _resolve_sdk_root_and_tier(sdk_root_arg, workspace_root) + if sdk_root is None: + return fail("renode.sdk-root-not-found", "Cannot locate alp-sdk root.") + sdk = SdkInfo(sdk_root, sdk_tier or "none") + + def fail_sdk(code: str, message: str, exit_code: ExitCode = ExitCode.RUNTIME_FAILURE): + return exit_code, data(), [_issue(code, "error", message)], [f"renode: {message}"], sdk, project + + # The bundle's own manifest, when it has one: it supplies both the SKU + # and the slice -> ELF resolution. Absent is fine (a bare bundle of + # ELFs) -- unlike the plain smoke, a missing manifest is NOT an error + # here. + manifest_path = os.path.join(bundle_dir, "system-manifest.yaml") + manifest = None + if os.path.isfile(manifest_path): + try: + _text, manifest = load_manifest(bundle_dir) + except ManifestUnavailable as err: + return fail_sdk("renode.manifest-unavailable", f"{manifest_path}: {err.detail}") + except ManifestInvalid as err: + message = f"{err.path}: {err.detail}" + if err.detail.startswith(_SCHEMA_VERSION_PREFIX): + return fail_sdk("renode.manifest-schema", message, ExitCode.VALIDATION_FAILURE) + return fail_sdk("renode.manifest-invalid", message) + + board = (board_arg or "").strip() + if board: + sku = board + else: + manifest_sku = (manifest.sku or "").strip() if manifest is not None else "" + if not manifest_sku: + return fail_sdk( + "renode.sku-unresolved", + "--sim-mode could not determine the board: pass --board " + "(no hw_info.sku in the bundle manifest).", + ) + sku = manifest_sku + known["sku"] = sku + + try: + elf = _resolve_bundle_elf(bundle_dir, manifest, core_arg) + except RenodeError as err: + return fail_sdk("renode.elf-missing", err.message) + if not os.path.isfile(elf): + return fail_sdk("renode.elf-missing", f"firmware ELF not found at {elf}.") + known["elf"] = elf + + # Only the `.repl` matters here: unlike the plain smoke, sim mode + # GENERATES its own boot script rather than including the SDK's `.resc`. + try: + repl, _resc_unused = platform_files_for_sku(sku, sdk_root) + except RenodeError as err: + return fail_sdk("renode.descriptor", err.message) + if not os.path.isfile(repl): + return fail_sdk("renode.descriptor-missing", f"missing Renode descriptor {repl}.") + known["platformStem"] = platform_stem_for_sku(sku) + known["repl"] = repl + + renode_bin = on_path("renode") + if renode_bin is None: + return fail_sdk( + "renode.binary-missing", + "`renode` binary not found on PATH. Install Renode (https://renode.io). " + "tan renode does not silently pass when Renode is missing.", + ) + + # BIND BEFORE ADVERTISING -- see `_bind_sim_listeners`'s own docstring. + try: + ctrl_sock, uart_sock, control_port, uart_port = _bind_sim_listeners() + except OSError as err: + return fail_sdk("renode.sim-bind-failed", f"could not bind a sim socket: {err}") + + descriptor_path = os.path.join(bundle_dir, "sim-descriptor.json") + descriptor_text = json.dumps(build_sim_descriptor(control_port, uart_port), indent=2) + "\n" + try: + with open(descriptor_path, "w", encoding="utf-8", newline="\n") as fh: + fh.write(descriptor_text) + except OSError as err: + _close_quietly(ctrl_sock, uart_sock) + return fail_sdk( + "renode.sim-descriptor-failed", f"could not write {descriptor_path}: {err}" + ) + # Recorded in `data` so a JSON consumer is not left assuming the file + # name and re-deriving the ports out of the descriptor it has to find + # first. + known["descriptor"] = descriptor_path + known["controlPort"] = control_port + known["uartPort"] = uart_port + + # Best-effort, exactly like the plain smoke: an unreadable or unexpected + # ELF seeds no VTOR and leaves Renode's own guess alone. + vtor = _best_effort_vtor(elf) + resc_path = os.path.join(bundle_dir, ".sim-boot.resc") + try: + with open(resc_path, "w", encoding="utf-8", newline="\n") as fh: + fh.write(build_sim_resc_text(repl, elf, vtor)) + except OSError as err: + _close_quietly(ctrl_sock, uart_sock) + return fail_sdk("renode.sim-boot-script-failed", f"could not write {resc_path}: {err}") + known["resc"] = resc_path + + log_path = _resolve_root_arg(log_arg, cwd, os.path.join(bundle_dir, "renode-sim.log")) + # From here on, `data()`'s `logPath` is THIS sim-specific value, not + # `base_log_path` -- mirrors the oracle's `report.log_path = ...` + # overwrite, which happens at this exact point (after repl/elf/ + # descriptor/ports are already known, before the argv is built). + known["logPath"] = log_path + + argv = build_sim_renode_argv(renode_bin, resc_path) + known["renodeArgv"] = argv + + issues: list[Issue] = [] + text: list[str] = [] + pin_issue = project_pin_issue(sdk_broken_pin, sdk_tier or "none") + if pin_issue is not None: + issues.append(pin_issue) + # The descriptor's `framebuffers`/`peripherals` are empty and the UART + # is silent while the per-SKU profile half of `tan-cli#77` is deferred. + # Saying so is not optional -- see `sim_profile_deferred_message`'s own + # docstring. + deferred = sim_profile_deferred_message(sku) + issues.append(_issue("renode.sim-profile-deferred", "warning", deferred)) + if expect is not None: + # Say so rather than ignoring it: sim mode routes the console to a + # socket, so there is no console text here for `--expect` to scan. + issues.append( + _issue( + "renode.expect-ignored", + "info", + "renode: --expect is ignored in --sim-mode (the console is served " + "on the UART socket, not scanned).", + ) + ) + + if not json_mode: + # Printed DIRECTLY (not appended to `text`, which text mode prints + # once at the very end): sim mode blocks for `--timeout` seconds + # serving sockets, and an operator needs the descriptor + ports the + # instant they exist. Verified stream-separated against the oracle. + print(f"tan renode --sim-mode: {sku} booting {os.path.basename(elf)}") + print(f" descriptor : {descriptor_path}") + print(f" control : tcp://127.0.0.1:{control_port}") + print( + f" uart : tcp://127.0.0.1:{uart_port} " + "(silent — the ram_console bridge is deferred, tan-cli#77)" + ) + # Text mode drops `issues`, so the warning has to be printed too or + # a human sees only the reassuring four lines above. + print(deferred) + sys.stdout.flush() + + try: + proc = _spawn_renode_sim(argv, log_path) + except OSError as err: + _close_quietly(ctrl_sock, uart_sock) + return fail_sdk("renode.run-failed", f"failed to run renode: {err}") + if proc.stdin is None or proc.stdout is None: + try: + proc.kill() + proc.wait() + except OSError: + pass + _close_quietly(ctrl_sock, uart_sock) + return fail_sdk( + "renode.run-failed", + "failed to run renode: the child's stdio pipes were not created.", + ) + + monitor = RenodeMonitor(proc.stdin, proc.stdout) + # Swallow the boot output FIRST and exclusively, THEN start accepting + # clients -- otherwise a client command races the boot drain for the + # monitor and captures boot text as its reply. + try: + monitor.drain_boot() + except RuntimeError as err: + _teardown_sim(monitor, proc) + _close_quietly(ctrl_sock, uart_sock) + return fail_sdk( + "renode.sim-monitor-failed", + f"Renode monitor never became ready: {err} (see {log_path}).", + ) + + threading.Thread(target=_serve_control, args=(ctrl_sock, monitor), daemon=True).start() + threading.Thread(target=_serve_uart_silent, args=(uart_sock,), daemon=True).start() + + _announce_ready(json_mode, timeout) + + # Hold the sockets open until the timeout, failing if Renode dies first. + early_exit: int | None = None + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + code = proc.poll() + if code is not None: + early_exit = code + break + time.sleep(0.25) + + _teardown_sim(monitor, proc) + _close_quietly(ctrl_sock, uart_sock) + + if early_exit is not None: + return fail_sdk( + "renode.sim-exited-early", + f"Renode exited early ({_exit_status_desc(early_exit)}); see {log_path}.", + ) + # Checked LAST and independently of everything above, exactly like the + # plain smoke's identical latch (issue #64): a Renode that boots, halts + # the CPU on its first instruction fetch and then sits there until the + # timeout looks -- to every other signal here -- like a healthy + # session. Renode's stdout is consumed by the monitor in this mode, so + # without the latch the halt would at best resurface as an `ERR` on + # whichever client command came next, and `tan` would still exit 0. The + # sim path is where a mis-seeded VTOR shows up this way. + if monitor.cpu_halted(): + msg = ( + "renode: the CPU halted on its first instruction fetch — no firmware " + f"code ever ran, even though the sim session came up (see {log_path})." + ) + issues.append(_issue("renode.cpu-halted", "error", msg)) + if not json_mode: + text.append(msg) + return ExitCode.RUNTIME_FAILURE, data(), issues, text, sdk, project + + return ExitCode.SUCCESS, data(), issues, text, sdk, project + + def renode( app_path: str = typer.Argument( ".", @@ -617,6 +1337,13 @@ def renode( help="If set, stop early (exit 0) when this substring appears in any console " "line; exit 1 if the run ends without it.", ), + sim_mode: bool = typer.Option( + False, + "--sim-mode", + help="Studio hardware-simulator mode: boot --image-bundle's firmware headless " + "and serve the control + UART sockets named by the bundle's " + "sim-descriptor.json. Requires --image-bundle; --expect is ignored.", + ), output_format: str = typer.Option( "text", "--format", metavar="FORMAT", help="Output format: text or json." ), @@ -650,6 +1377,7 @@ def renode( expect=expect, json_mode=json_mode, cwd=cwd, + sim_mode_arg=sim_mode, ) except Exception as err: # noqa: BLE001 -- the whole point of this guard # Anything reaching here is a tan bug, reported as one with an @@ -666,3 +1394,10 @@ def renode( for line in text_lines: print(line, file=sys.stderr) raise typer.Exit(int(exit_code)) + + +# tan-cli#261: adds the seven oracle `GlobalArgs` flags this command was +# still missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--quiet`/ +# `--target`/`--verbose`) on top of `--board-yaml`, already declared and read +# above; see `tan.core.global_flags`. +renode = accept_global_flags(renode) diff --git a/python/tan/commands/run_cmd.py b/python/tan/commands/run_cmd.py index 141a22b9..da5e7f30 100644 --- a/python/tan/commands/run_cmd.py +++ b/python/tan/commands/run_cmd.py @@ -61,9 +61,16 @@ from tan.commands import flash_cmd from tan.commands.build import execute -from tan.commands.build_cmd import BuildError, _abs_posix, _build, resolve_sdk_root_ladder +from tan.commands.build_cmd import ( + BuildError, + _abs_posix, + _build, + _is_sdk_root, + resolve_sdk_root_ladder, +) from tan.commands.sdk_cmd import project_pin_issue from tan.core.flash_plan import resolve_artefact_path +from tan.core.global_flags import accept_global_flags from tan.core.plan_exec import normalize_path from tan.core.run import RunAction, decide_run_action, native_sim_exe_beside, native_sim_slice from tan.core.system_manifest import SystemManifestError, parse_system_manifest @@ -371,6 +378,25 @@ def run( # `ALP_SDK_ROOT` tier (tried and reverted -- see `resolve_sdk_root_ladder`'s # own docstring). resolved_sdk_root, sdk_tier, sdk_broken_pin = resolve_sdk_root_ladder(sdk_root, workspace_root) + # tan-cli#257/#258 -- the exact guard `build_cmd.build` applies, for the + # exact same reason: this line was a VERBATIM COPY of the one that carried + # the defect, so fixing only `build` would have left its twin here. + # `resolve_sdk_root_ladder` returns an explicit `--sdk-root` UNVALIDATED + # (I-31 terminal-for-REPORTING, matching the oracle's + # `resolve_sdk_tiered`), which is correct for a caller that only reports + # the tier and wrong for one that ACTS on the path: a bogus `--sdk-root` + # sailed through as `sdk.sourceTier: "sdkRootFlag"` and was then refused + # for the NEXT missing thing, telling the customer their project is broken + # when the flag they had just typed is what was wrong. + # + # Guarded HERE rather than inside the shared ladder because every other + # caller depends on it staying unvalidated -- the same placement + # `build_cmd`, `clean_cmd.sdk_root_resolves` and `flash_cmd._resolve_sdk` + # already chose. An unresolvable explicit root is treated as no root at + # all, so the refusal downstream is the honest "no alp-sdk checkout found" + # and no `sdk` key is emitted, matching the oracle. + if sdk_tier == "sdkRootFlag" and not _is_sdk_root(resolved_sdk_root): + resolved_sdk_root = None sdk_root = str(resolved_sdk_root) if resolved_sdk_root is not None else None sdk = SdkInfo(sdk_root, sdk_tier) if sdk_root is not None else None # Same normalized, workspace-root-anchored stamp identity `build_cmd.build` @@ -413,3 +439,10 @@ def run( for line in text_lines: print(line, file=sys.stderr) raise typer.Exit(int(exit_code)) + + +# tan-cli#261: adds the seven oracle `GlobalArgs` flags this command was +# still missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--quiet`/ +# `--target`/`--verbose`) on top of `--board-yaml`, already declared and read +# above; see `tan.core.global_flags`. +run = accept_global_flags(run) diff --git a/python/tan/commands/scaffold_cmd.py b/python/tan/commands/scaffold_cmd.py new file mode 100644 index 00000000..c102aab2 --- /dev/null +++ b/python/tan/commands/scaffold_cmd.py @@ -0,0 +1,469 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan scaffold` -- scaffold one module (a source/header pair + README) into +an EXISTING tan project. Distinct from `tan init`, which scaffolds a whole new +project: this command never touches `board.yaml`, never resolves an SDK, and +its template id space (`tan.core.module_template.MODULE_TEMPLATE_IDS`) is a +different, smaller registry than `tan init`'s own six. + +Composition, not logic: resolve the module name + template + destination +(`--name`/`--template`/`--destination`, and `tan.core.module_template`'s +registry), ask it for the planned three files, diff them against disk +(`tan.core.scaffold.collect_file_changes`), then preview / guard / write -- +folding whatever comes back into exactly one envelope. Mirrors +`crates/tan-cli/src/commands/scaffold.rs`. + +**`--name` is REQUIRED, with NO non-interactive default.** Unlike `tan init`'s +`--template` (defaults to `zephyr-app`) or `--name` (defaults to an empty +subdirectory), a module scaffold has no sane default name -- so the +non-interactive contract is a REFUSAL (`scaffold.name-required`, exit 2), +never a default (`crates/tan-cli/src/commands/scaffold.rs`'s own history, +CHANGELOG.md's #187-follow-up entry: "its non-interactive contract is a +refusal, not a default, since a module name has no sane one"). `--template` +DOES have a non-interactive default (`sensor-driver`, the registry's first +entry) when omitted. + +**Interactivity mirrors the oracle's `GlobalArgs::can_prompt()` +(`crates/tan-cli/src/cli.rs`) exactly**: may only prompt when NOT +`--non-interactive`, NOT `--ci`, NOT `--format json`, AND both stdin and +stderr are real terminals. The last two matter more than they look: a +prompting library renders to stderr and reads through the controlling +terminal, so `stdin=tty, stderr=piped` -- every wrapper that captures output +while inheriting the terminal -- still cannot be prompted safely and must +refuse rather than hang. No CI runner, and no `pytest` subprocess, has a TTY, +so `--name`/`--template` are effectively always required in an automated run; +this is intentional (the module docstring for `can_prompt` documents the same +for the Rust binary: "no CI runner has a TTY"). The interactive fallback here +uses `click.prompt`/`click.Choice` rather than a `Select`/`Text` TUI widget, +the same simplification `tan.commands.new_som_cmd` already made and documents +(no arrow-key menu dependency for a path automated tests never exercise). + +Every failure is a coded issue, never a traceback -- the backstop at the +bottom of [`scaffold`] converts any unexpected exception into +`scaffold.internal-failure` rather than letting it escape, matching +`init_cmd`/`debug_config_cmd`'s own catch-all. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from pathlib import Path + +import click +import typer + +from tan.core.consent import can_prompt +from tan.core.module_template import ( + DEFAULT_MODULE_TEMPLATE_ID, + MODULE_TEMPLATE_IDS, + create_module_scaffold_plan, +) +from tan.core.scaffold import FileChange, PlannedFile, ScaffoldWriteError, collect_file_changes +from tan.core.scaffold import scaffold_tree_preview as _tree_preview +from tan.core.scaffold import write_files +from tan.envelope import Envelope, Issue, Project, emit +from tan.exit_codes import ExitCode + +#: `data.schemaVersion` for this command's payload. +DATA_SCHEMA_VERSION = "1" + + +class ScaffoldError(Exception): + """A failure with its issue code and exit code already decided -- the + ONE exception type every resolution/planning/write step in [`scaffold`] + raises, mirrors `init_cmd.InitError`'s reason for existing: a single + exception class lets the whole computation run inside ONE `try`, with the + error-shaped envelope built exactly once, after it, in a SIBLING `except` + clause. Calling an emit-and-`typer.Exit` helper from a handler that is + itself still lexically nested INSIDE that same `try` does not work -- + `typer.Exit` subclasses `RuntimeError`, so raising it from a nested + `except ScaffoldWriteError:` block is still within the outer try's + dynamic extent and gets re-caught by the outer `except Exception:` + backstop, turning a clean exit 3 into a misreported `scaffold.internal- + failure` at exit 5 (caught by this port's own oracle-diff smoke test, + not a golden -- there is no committed fixture for this shape). + + `partial` carries the files that DID land when a write failed part-way: + `written: []` for a module half-written to disk would contradict the + filesystem, the same reasoning `InitError.partial` documents. + """ + + def __init__( + self, + code: str, + message: str, + exit_code: ExitCode, + *, + partial: tuple[list[str], list[str]] = ([], []), + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.exit_code = exit_code + self.partial = partial + + +@dataclass +class _Outcome: + """A completed (non-error) run: preview, overwrite-guard refusal, or + write. Built and returned rather than emitted in place, so the exception + guard in [`scaffold`] can wrap the whole computation without also + catching `typer.Exit`.""" + + template_id: str + module_name: str + normalized_name: str + destination: str + preview: bool + file_changes: list[FileChange] + files: list[PlannedFile] + written: list[str] = field(default_factory=list) + unchanged: list[str] = field(default_factory=list) + exit_code: ExitCode = ExitCode.SUCCESS + issue: Issue | None = None + + +# --------------------------------------------------------------------------- +# Interactivity +# --------------------------------------------------------------------------- + + +def _need_name() -> ScaffoldError: + return ScaffoldError( + "scaffold.name-required", + "Module name is required. Use --name or run interactively.", + ExitCode.VALIDATION_FAILURE, + ) + + +def _cancelled() -> ScaffoldError: + return ScaffoldError("scaffold.cancelled", "Cancelled.", ExitCode.RUNTIME_FAILURE) + + +def _resolve_module_name(name: str | None, interactive: bool) -> str: + if name is not None: + return name + if not interactive: + raise _need_name() + try: + raw = click.prompt("Module name") + except click.exceptions.Abort as err: + raise _cancelled() from err + stripped = raw.strip() + if not stripped: + raise _need_name() + return stripped + + +def _resolve_template(template: str | None, interactive: bool) -> str: + if template is not None: + if template not in MODULE_TEMPLATE_IDS: + raise ScaffoldError( + "scaffold.invalid-template", + f"Unknown module template '{template}'.", + ExitCode.VALIDATION_FAILURE, + ) + return template + if not interactive: + return DEFAULT_MODULE_TEMPLATE_ID + try: + return click.prompt("Select a module template", type=click.Choice(MODULE_TEMPLATE_IDS)) + except click.exceptions.Abort as err: + raise _cancelled() from err + + +# --------------------------------------------------------------------------- +# Envelope assembly +# --------------------------------------------------------------------------- + + +def _data( + *, + template_id: str, + module_name: str, + normalized_module_name: str, + destination: str, + preview: bool, + file_changes: list[FileChange], + written: list[str], + unchanged: list[str], +) -> dict: + return { + "schemaVersion": DATA_SCHEMA_VERSION, + "templateId": template_id, + "moduleName": module_name, + "normalizedModuleName": normalized_module_name, + "destination": destination, + "preview": preview, + "fileChanges": [{"relativePath": c.relative_path, "kind": c.kind} for c in file_changes], + "written": written, + "unchanged": unchanged, + } + + +_EMPTY_DATA_FIELDS = { + "template_id": "", + "module_name": "", + "normalized_module_name": "", + "destination": "", + "preview": False, + "file_changes": [], +} + + +def _stderr(line: str) -> None: + print(line, file=sys.stderr) + + +def _emit_error(json_mode: bool, err: ScaffoldError) -> None: + """An error before (or during) a write: `project.root: null`, every + plan-shaped field empty. `written`/`unchanged` are usually empty too, EXCEPT + on a part-way write failure (`err.partial`), where they carry whatever + landed before it -- reporting `written: []` there would contradict the + filesystem. Mirrors `error_run`/`write_error_run` in the Rust + (`scaffold.rs`), which the wire shape is otherwise identical between. + """ + written, unchanged = err.partial + if json_mode: + emit( + Envelope( + "scaffold", + Project(root=None, board_yaml=None), + _data(**_EMPTY_DATA_FIELDS, written=written, unchanged=unchanged), + [Issue(err.code, "error", err.message)], + err.exit_code, + ) + ) + else: + _stderr(f"scaffold: {err.message}") + raise typer.Exit(int(err.exit_code)) + + +def _emit_outcome(json_mode: bool, outcome: _Outcome) -> None: + project = Project(root=outcome.destination, board_yaml=None) + if json_mode: + emit( + Envelope( + "scaffold", + project, + _data( + template_id=outcome.template_id, + module_name=outcome.module_name, + normalized_module_name=outcome.normalized_name, + destination=outcome.destination, + preview=outcome.preview, + file_changes=outcome.file_changes, + written=outcome.written, + unchanged=outcome.unchanged, + ), + [outcome.issue] if outcome.issue is not None else [], + outcome.exit_code, + ) + ) + elif outcome.preview: + _stderr( + f"scaffold: preview for module '{outcome.normalized_name}' " + f"(template '{outcome.template_id}')" + ) + # `_tree_preview` already ends in one `\n` (one per listed path); NOT + # stripped -- `_stderr`'s own `print()` adds a second, matching the + # oracle's own two-`CommandRun.text` -> `println!` shape byte-for-byte + # (measured: `tan scaffold --preview` ends the tree with `\n\n`). + _stderr(_tree_preview(outcome.files)) + elif outcome.exit_code != ExitCode.SUCCESS: + # The overwrite guard. Deliberately NOT `outcome.issue.message` -- + # the Rust's text-mode line here is a separate, shorter, hardcoded + # string (`scaffold.rs`'s guard block), not the JSON issue message + # ("One or more files would be overwritten. Use --force to allow + # updates.") rendered with a prefix; measured against the oracle. + _stderr("scaffold: would overwrite existing files; use --force to proceed.") + else: + _stderr( + f"scaffold: created module '{outcome.normalized_name}' " + f"(template '{outcome.template_id}')" + ) + _stderr(f" written: {len(outcome.written)}, unchanged: {len(outcome.unchanged)}") + raise typer.Exit(int(outcome.exit_code)) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def scaffold( + ctx: typer.Context, + template: str = typer.Option( + None, + "--template", + metavar="TEMPLATE", + help=f"Module template id ({', '.join(MODULE_TEMPLATE_IDS)}).", + ), + name: str = typer.Option( + None, "--name", metavar="NAME", help="Module name (required)." + ), + destination: str = typer.Option( + None, + "--destination", + metavar="DESTINATION", + help="Destination project root (default: current directory or --project).", + ), + preview: bool = typer.Option( + False, "--preview", help="Show planned files without writing anything." + ), + force: bool = typer.Option( + False, "--force", help="Allow overwriting existing files." + ), + project: str = typer.Option( + None, "--project", metavar="PATH", help="Project root (defaults to current directory)." + ), + board_yaml: str = typer.Option( + None, + "--board-yaml", + metavar="PATH", + help="Explicit board.yaml path (overrides project resolution).", + ), + sdk_root: str = typer.Option( + None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." + ), + target: str = typer.Option( + None, + "--target", + metavar="EMIT", + help="Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf).", + ), + all_targets: bool = typer.Option( + False, "--all", help="Run command against all relevant targets." + ), + output_format: str = typer.Option( + None, "--format", metavar="FORMAT", help="Output format: text or json." + ), + verbose: bool = typer.Option(False, "--verbose", help="Emit additional diagnostic detail."), + quiet: bool = typer.Option(False, "--quiet", help="Suppress non-essential output."), + no_color: bool = typer.Option( + False, "--no-color", help="Disable ANSI color in text output." + ), + non_interactive: bool = typer.Option( + False, + "--non-interactive", + help="Never prompt; fail instead of asking when a required value is missing.", + ), + ci: bool = typer.Option( + False, "--ci", help="CI mode: implies non-interactive and disables color." + ), +) -> None: + """Scaffold a module into an existing project.""" + # `--board-yaml`/`--sdk-root`/`--target`/`--all`/`--verbose`/`--quiet`/ + # `--no-color` are members of the oracle's clap `GlobalArgs` (`global = + # true`), so the real `tan scaffold` parses and lists all of them in + # `--help` -- but `crates/tan-cli/src/commands/scaffold.rs::run` reads + # only `g.project` and `g.can_prompt()` (non_interactive/ci/format). + # Declared (not `hidden=True`) so `tan scaffold --help` matches the + # oracle's own listing; genuinely unread otherwise, matching `init_cmd`'s + # identical block for its own five ignored globals. + del board_yaml, sdk_root, target, all_targets, verbose, quiet, no_color + + resolved_format = output_format if output_format is not None else (ctx.obj or {}).get( + "format" + ) or "text" + if resolved_format not in ("text", "json"): + raise typer.BadParameter( + f"'{resolved_format}' (choose from 'text', 'json')", param_hint="--format" + ) + json_mode = resolved_format == "json" + interactive = can_prompt(non_interactive=non_interactive, ci=ci, json_mode=json_mode) + + try: + module_name = _resolve_module_name(name, interactive) + template_id = _resolve_template(template, interactive) + + dest = destination if destination else (project if project else ".") + project_root = Path(dest) + + try: + plan = create_module_scaffold_plan(template_id, module_name) + except ValueError as err: + # Re-raised as `ScaffoldError`, never emitted from here directly: + # this `except` is still lexically INSIDE the outer `try` below, + # so a `typer.Exit` raised from an emit helper called here would + # be re-caught by this same function's own `except Exception` + # backstop (`typer.Exit` subclasses `RuntimeError`) -- see + # `ScaffoldError`'s own docstring for the mechanism and how this + # was actually caught (an oracle-diff smoke test, not a golden). + raise ScaffoldError( + "scaffold.invalid-name", str(err), ExitCode.VALIDATION_FAILURE + ) from err + + changes = collect_file_changes(project_root, plan.files) + has_updates = any(c.kind == "update" for c in changes) + + if preview: + # Before the overwrite guard, deliberately -- a preview touches no + # disk, so it has nothing to be guarded against (same ordering + # `tan init` learned the hard way; see `tan.core.scaffold`'s + # `write_files` docstring for the sibling incident). + outcome = _Outcome( + template_id=template_id, + module_name=module_name, + normalized_name=plan.normalized_name, + destination=dest, + preview=True, + file_changes=changes, + files=plan.files, + ) + elif has_updates and not force: + outcome = _Outcome( + template_id=template_id, + module_name=module_name, + normalized_name=plan.normalized_name, + destination=dest, + preview=False, + file_changes=changes, + files=plan.files, + exit_code=ExitCode.WRITE_FAILURE, + issue=Issue( + "scaffold.would-overwrite", + "error", + "One or more files would be overwritten. Use --force to allow updates.", + ), + ) + else: + try: + result = write_files(project_root, plan.files) + except ScaffoldWriteError as err: + raise ScaffoldError( + "scaffold.write-failed", + f"Failed to write files: {err}", + ExitCode.WRITE_FAILURE, + partial=(err.partial.written, err.partial.unchanged), + ) from err + outcome = _Outcome( + template_id=template_id, + module_name=module_name, + normalized_name=plan.normalized_name, + destination=dest, + preview=False, + file_changes=changes, + files=plan.files, + written=result.written, + unchanged=result.unchanged, + ) + except ScaffoldError as err: + _emit_error(json_mode, err) + return + except Exception as err: # noqa: BLE001 -- the backstop; see the module docstring + # `typer.Exit` cannot reach here: it is only ever raised from + # `_emit_error`, called from the SIBLING `except ScaffoldError` clause + # above -- outside this try's dynamic extent, so it propagates + # straight out rather than looping back into this handler. + _emit_error( + json_mode, + ScaffoldError( + "scaffold.internal-failure", + f"scaffold failed unexpectedly: {err.__class__.__name__}: {err}", + ExitCode.INTERNAL_FAILURE, + ), + ) + return + + _emit_outcome(json_mode, outcome) diff --git a/python/tan/commands/sdk_cmd.py b/python/tan/commands/sdk_cmd.py index 6dec41d7..27b97d4b 100644 --- a/python/tan/commands/sdk_cmd.py +++ b/python/tan/commands/sdk_cmd.py @@ -28,12 +28,22 @@ accident of it. **Network is opt-in.** `sdk list` is the one verb that talks to GitHub, and it -only does so behind an explicit `--online`; without the flag it refuses with a -coded issue rather than reaching out. The fetch itself carries an explicit -timeout, because a `urlopen` with no timeout inherits the socket default of -"forever" and a CI job driving `--format json` would hang until the runner -killed it (I-23's failure mode, arrived at through a socket instead of a -prompt). +only does so behind an explicit `--online`. tan-cli#351: without the flag it +answers OFFLINE, at exit 0 -- not a refusal. The oracle has no `--online` flag +at all and reaches the network unconditionally on every `sdk list` call +(measured: `--help` lists no such option; a live run with network reachable +succeeds at exit 0 with no flag given), so gating the call is this port's OWN +addition, for hermeticity (I-23) -- a command that silently opens a socket +cannot be driven from a hermetic test, an air-gapped host, or a fixture. That +gate is not a verdict on anything the caller did wrong, so it must not exit +non-zero: the bare answer says plainly that the releases it reports are +UPSTREAM and that `--online` is the switch that fetches them, the same way +`sdk current` answers "nothing configured" at exit 0 instead of failing (see +`_run_list`'s own docstring for the full reasoning). The fetch itself carries +an explicit timeout, because a `urlopen` with no timeout inherits the socket +default of "forever" and a CI job driving `--format json` would hang until the +runner killed it (I-23's failure mode, arrived at through a socket instead of +a prompt). **No SDK is ever shelled.** Nothing here runs `python -m alp_cli` or `alp_project.py`: readiness is a stat of `scripts/alp_project.py`, @@ -79,6 +89,7 @@ import typer +from tan.core.global_flags import accept_global_flags from tan.envelope import Envelope, Issue, Project, SdkInfo, emit from tan.exit_codes import ExitCode from tan.net import default_ssl_context @@ -847,23 +858,43 @@ def _run_current(*, json_mode: bool, sdk_root: str | None, workspace_root: Path) def _run_list(*, json_mode: bool, online: bool) -> None: """`tan sdk list` -- the published alp-sdk releases. - `--online` is required. The Rust reaches the network unconditionally here; - this port gates it because a command that silently opens a socket cannot be - driven from a hermetic test, an air-gapped host, or a fixture. The refusal - is a normal coded envelope, so a consumer sees a reason rather than a hang. + tan-cli#351: bare `sdk list` (no `--online`) answers OFFLINE, at exit 0. + Measured first, since this file already carries the reasoning for why the + port diverges from the oracle here: the oracle (`target/debug/tan.exe`, + tan 0.4.1) has no `--online` flag at all -- `sdk list --help` lists none -- + and reaches the network unconditionally on every `sdk list` call (a live + run against a reachable network succeeds at exit 0 with no flag given). + Gating the fetch behind `--online` is this PORT's own addition, for + hermeticity (I-23): a command that silently opens a socket cannot be + driven from a hermetic test, an air-gapped host, or a fixture. That gate + is not, itself, a verdict on anything the caller did wrong -- there is no + "failure" here to report, only a question (`sdk list` answers what alp-sdk + has published upstream) that needs an explicit flag to actually reach the + network for. Exiting non-zero for it (as this used to) treated a normal, + everyday invocation the same as a real error, which is exactly the + asymmetry `sdk current` never had: "nothing configured" is exit 0 there, + and "list needs `--online`" now is here too. The message says plainly + what `sdk list` reports (UPSTREAM releases) and that `--online` is the + switch that fetches them, rather than reporting the missing flag as a + network requirement failure. """ if not online: - _fail( + _emit( json_mode=json_mode, data=_list_data([]), - code="network-required", - message=( - "`sdk list` queries the GitHub releases API. Re-run with " - "`--online` to allow the network request." - ), + issues=[ + Issue( + "sdk.network-required", + "warning", + "`sdk list` reports the Alp SDK releases published upstream " + "on GitHub -- there is no local/offline copy to answer from. " + "Add --online to fetch them.", + ) + ], + exit_code=ExitCode.SUCCESS, text_lines=[ - "sdk list: this command needs network access.", - "Re-run as `tan sdk list --online`.", + "sdk list: reports Alp SDK releases published upstream on GitHub.", + "Add --online to fetch them: `tan sdk list --online`.", ], ) return @@ -1046,3 +1077,12 @@ def sdk( text_lines=[f"sdk: unexpected failure: {err}"], exit_code=ExitCode.INTERNAL_FAILURE, ) + + +# tan-cli#261: adds the eight oracle `GlobalArgs` flags this command was +# missing entirely (`--all`/`--board-yaml`/`--ci`/`--no-color`/ +# `--non-interactive`/`--quiet`/`--target`/`--verbose`); see +# `tan.core.global_flags`. All inert here: every envelope `sdk` emits reports +# `Project(root=None, board_yaml=None)`, an SDK-wide fact with no project of +# its own to anchor a `--board-yaml`/`--target` on. +sdk = accept_global_flags(sdk) diff --git a/python/tan/commands/size_cmd.py b/python/tan/commands/size_cmd.py index c9d9c285..e34d4850 100644 --- a/python/tan/commands/size_cmd.py +++ b/python/tan/commands/size_cmd.py @@ -58,6 +58,7 @@ resolve_project_context, ) from tan.commands.sdk_cmd import project_pin_issue +from tan.core.global_flags import accept_global_flags from tan.core.pending import is_pending_placeholder from tan.core.size import ( MemoryBudget, @@ -642,3 +643,12 @@ def size( for line in outcome.text: stream.write(f"{line}\n") raise typer.Exit(int(outcome.exit_code)) + + +# tan-cli#261: adds the five oracle `GlobalArgs` flags this command was still +# missing (`--all`/`--non-interactive`/`--quiet`/`--target`/`--verbose`) on +# top of `--no-color`/`--ci`, already declared and read above (`_use_color`); +# see `tan.core.global_flags`. `ctx: typer.Context` (this command's own +# `_HONOURS_ROOT_FORMAT` seam) is untouched -- appended parameters are all +# keyword-only Options, never repositioned relative to it. +size = accept_global_flags(size) diff --git a/python/tan/commands/support_bundle_cmd.py b/python/tan/commands/support_bundle_cmd.py new file mode 100644 index 00000000..4b921faf --- /dev/null +++ b/python/tan/commands/support_bundle_cmd.py @@ -0,0 +1,835 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan support-bundle` -- export a diagnostic bundle (inspect + trace + +doctor) to a JSON file for attaching to a bug report. + +Port of `crates/tan-cli/src/commands/support_bundle.rs`. Composes three +sections into one written file: the resolved debug context + its resolved +values (`inspect_cmd`'s own model), the generation-trace decisions +(`trace_cmd`'s own model), and a doctor report -- then returns a stdout +envelope naming the written path + a decision count. Exit follows the +oracle's rule verbatim: `DOCTOR_FAILURE` (4) when the bundled doctor +section's `summary.fail > 0` (`support_bundle.rs`: `let exit = if +doctor.summary.fail > 0 { ExitCode::DoctorFailure }`), else `SUCCESS` (0). +The bundle file is still written either way. An unsupported target/server +pairing -> `DOCTOR_FAILURE` (4) too; a bad `--target-kind`/`--server` value +-> `INTERNAL_FAILURE` (5). + +**The doctor section is the DEBUG-focused report, not this port's `tan +doctor` checklist** (tan-cli#357). Until that issue this module substituted +`doctor_cmd._collect`'s build/flash-readiness list and pinned the exit at +`SUCCESS`, so a bundle attached to a debug failure carried no debugger state +at all while automation read `ok: true` next to error-severity issues. +Measured against the oracle (empty PATH, no SDK): rust rc=4 ok=false +issues=[`support-bundle.sdkRoot`, `support-bundle.hostPrerequisites`]; the +port answered rc=0 ok=true with six error issues. + +[`_debug_doctor_report`] is therefore the port of `tan_core::debug::doctor:: +build_doctor_report` + `tan-cli`'s two appends (`append_host_prerequisites`, +`append_host_environment`). The base/target-branch checks are written here +because nothing else in this port produces them -- `support-bundle` is this +port's ONLY debug-report consumer, the debug half of `tan doctor` still +being unported (`doctor_cmd.py`'s own module docstring). The four host +checks are NOT rewritten: they are harvested by name from +`doctor_cmd._collect`, which already owns each one, so a change there +reaches the bundle with no second copy to drift (see +[`_host_checks_from_doctor`]). `--target-kind`/`--server` now genuinely +change the report, exactly as they do in the oracle. + +**One harvested check is capped, not passed through raw** (tan-cli#374 +finding 1): `doctor_cmd`'s `longPaths` carries a Windows-only `fail` arm +(tan-cli#306) the oracle cannot reach, and feeding it straight into this +command's verdict made a first-run customer host (registry `LongPathsEnabled` +on, no global `.gitconfig`) exit 4 where the oracle exits 0 with no issues, +on every target/server shape. See [`_demote_long_paths_fail`]. + +REDACTION POLICY (this port's own decision -- the oracle does not redact at +all; verified: a fresh bundle from a freshly-built `target/debug/tan.exe` +writes the literal `Home directory has no spaces: C:\\Users\\` +straight into the file). Every string value in the WRITTEN FILE -- never the +stdout envelope, whose `data.outputPath` must stay a real, followable path for +the caller that just asked for it -- has every literal occurrence of the +resolved home directory (`%USERPROFILE%` on Windows, `$HOME` elsewhere, in +both its native and its posix-slash spelling) replaced with the placeholder +``. This is deliberately narrow, not a blanket path-scrubber: it targets +the one concrete PII class this bundle contains today -- the OS account name, +which rides on almost every absolute path the bundle reports (`workspaceRoot`, +`sdkRoot`, every trace `outputPath`/command line, and the doctor section's own +`homePath`-flavoured detail strings) -- while leaving a workspace/SDK root +OUTSIDE the home directory legible on purpose: a maintainer reading an +attached bundle needs the real project layout to diagnose a path problem, and +none of this command's own inputs (tool presence/versions, filesystem facts) +carry a token or credential to redact beyond the account name. See +[`_redact`]/[`_home_variants`]. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, replace +from typing import Any + +import typer + +from tan.commands import doctor_cmd +from tan.commands.inspect_cmd import ( + ResolvedDebugContext, + collect_resolved_values, + resolve_debug_project_context, +) +from tan.commands.trace_cmd import ( + TraceTargetError, + build_trace_decisions, + resolve_trace_targets, +) +from tan.core.debug_launch import ( + BAREMETAL_MCU, + GDBSERVER, + JLINK, + NATIVE_HOST, + OPENOCD, + PYOCD, + SERVER_NONE, + YOCTO_USERSPACE, + ZEPHYR_MCU, + DebugConfigError, + is_server_supported_for_target, + parse_server_kind, + parse_target_kind, +) +from tan.core.timestamp import generated_at_iso +from tan.envelope import Envelope, Issue, Project, SdkInfo, emit +from tan.exit_codes import ExitCode + +#: `data.schemaVersion` for this command's stdout payload, and the bundle +#: file's own top-level + per-section schema versions (all "1", like the +#: oracle). +DATA_SCHEMA_VERSION = "1" + + +# --------------------------------------------------------------------------- +# Redaction -- see the module docstring's "REDACTION POLICY". +# --------------------------------------------------------------------------- + + +def _home_variants() -> tuple[str, ...]: + """The resolved home directory, in both its native and posix-slash + spelling -- the bundle mixes both (native in doctor detail strings and + `--destination`-normalised paths, posix in `workspaceRoot`/`sdkRoot`/ + `boardYamlPath`), so redaction has to look for both. Empty when the host + has neither `USERPROFILE` nor `HOME` set -- redacts nothing rather than + guessing.""" + home = os.environ.get("USERPROFILE" if os.name == "nt" else "HOME") + if not home: + return () + return tuple({home, home.replace("\\", "/")}) + + +def _redact(value: Any, home_variants: tuple[str, ...]) -> Any: + """Recursively replace every literal `home_variants` occurrence in every + string this bundle payload carries with ``. Walks dicts/lists; + every other JSON-safe type (bool/int/float/None) passes through + unchanged.""" + if isinstance(value, str): + for variant in home_variants: + value = value.replace(variant, "") + return value + if isinstance(value, dict): + return {k: _redact(v, home_variants) for k, v in value.items()} + if isinstance(value, list): + return [_redact(v, home_variants) for v in value] + return value + + +# --------------------------------------------------------------------------- +# Bundle assembly +# --------------------------------------------------------------------------- + + +def _timestamp_for_file(generated_at: str) -> str: + """Makes an ISO timestamp filename-safe: `:`/`.` -> `-`. Mirrors the + oracle's `timestamp_for_file`.""" + return "".join("-" if c in ":." else c for c in generated_at) + + +def _create_bundle_trace_decisions( + context: ResolvedDebugContext, target: str | None, focus: str | None +) -> list[dict[str, Any]]: + """The bundle's own trace section: `Planned` decisions when an SDK root + resolved, else one `Failed` placeholder -- port of `support_bundle.rs`'s + `create_bundle_trace_decisions`. + + Deliberately checks ONLY `context.sdk_root is not None`, never + `board_yaml_exists` -- measured against the oracle: a project with a + resolved SDK but a MISSING board.yaml still gets four `Planned` decisions + here (`decisionCount: 4`), unlike bare `tan trace`, which refuses outright + on a missing board.yaml. `workspace_root`/`board_yaml_path` need no + presence check of their own in this port: both are unconditionally + resolved by [`resolve_debug_project_context`] (see that module's + docstring), exactly mirroring why the oracle's own three-way `Option` + match only ever turns on `sdk_root`. + """ + if context.sdk_root is None: + decisions: list[dict[str, Any]] = [ + { + "key": "generation.targets", + "outcome": "failed", + "detail": ( + "Generation targets were not traced because project context " + "is unresolved." + ), + } + ] + else: + targets = resolve_trace_targets(target) # may raise TraceTargetError + decisions = build_trace_decisions( + context.workspace_root, + context.sdk_root, + context.board_yaml_path, + context.python_binary, + targets, + None, # the focus-path decision is appended once, below + ) + + if focus is not None: + decisions.append( + { + "key": f"config.path.{focus}", + "outcome": "planned", + "detail": ( + "Path-level trace was requested and captured as part of " + "bundle metadata." + ), + } + ) + return decisions + + +#: The `DebuggerExtensionsState` the standalone binary reports, verbatim from +#: `crates/tan-cli/src/commands/doctor.rs::standalone_debugger_extensions`. +#: The three flags are an inherited assumption from the VS Code extension -- +#: only an extension host can enumerate its own marketplace extensions -- and +#: `observable: false` is what stops anything reading them as facts: every +#: derived check renders `unknown` instead of claiming "vadimcn.vscode-lldb is +#: installed." on a headless container (#102). +STANDALONE_DEBUGGER_EXTENSIONS = { + "cortexDebug": True, + "cppTools": True, + "codeLLDB": True, + "observable": False, +} + +#: Per-server executable candidates, first hit on PATH wins -- port of +#: `tan_core::debug::context::collect_runtime_capabilities_from_commands`. The +#: reported value is the COMMAND NAME, not the resolved path: that is what the +#: oracle puts in the check detail (`.map(|c| (*c).to_string())`). +_RUNTIME_EXECUTABLES: dict[str, tuple[str, ...]] = { + JLINK: ("JLinkGDBServerCL", "JLinkGDBServer"), + OPENOCD: ("openocd",), + PYOCD: ("pyocd",), + GDBSERVER: ("gdb", "arm-none-eabi-gdb"), + SERVER_NONE: ("lldb-dap", "lldb"), +} + +#: The host checks the oracle appends to the pure report AFTER building it +#: (`append_host_prerequisites` then `append_host_environment`), in that +#: order. `bootstrapManifest` has no oracle counterpart as a CHECK -- Rust +#: folds a rejected `metadata/bootstrap.json` into `hostPrerequisites`' +#: own detail via `manifest_error`, this port reports it as a sibling warn -- +#: so it rides here rather than being dropped: a bundle whose prerequisite +#: list came from tan's fallback instead of the SDK's manifest must say so. +#: `longPaths` is Windows-only and simply absent elsewhere. Its `fail` status +#: is demoted before it ever reaches this command's verdict -- see +#: [`_demote_long_paths_fail`]. +_HOST_CHECK_ORDER = ( + "bootstrapManifest", + "hostPrerequisites", + "zephyrSdkAvailableForHost", + "longPaths", + "homePath", +) + + +def _demote_long_paths_fail(check: doctor_cmd.Check) -> doctor_cmd.Check: + """tan-cli#374 finding 1. tan-cli#306 widened `doctor_cmd.long_paths_check` + with a Windows-only `fail` arm (registry `LongPathsEnabled` on, git's own + `core.longpaths` not) that the oracle cannot reach at all: Rust's + `long_paths_check` (`crates/tan-core/src/host_env.rs:300-330`) takes only + the registry axis and never returns worse than `Warn` -- confirmed by + RUNNING the oracle, not by reading it. Every OTHER `longPaths` state + (`warn`/`pass`) already matches the oracle's own verdict for that state + and is left alone; only this one, oracle-unreachable combination is + downgraded. + + Feeding the undemoted check into this command's verdict was exactly the + bug: on a first-run customer host (registry on, no global `.gitconfig`, + so git's own flag is unset) it made `support-bundle` exit 4 -- doctor + failure -- where the oracle exits 0 with no issues at all, on every + target/server shape. `tan doctor` KEEPS the fail arm unchanged (that is + #306's real fix, for the command that actually gates a build and where + "fail fast on a certain west-update break" is the right call); this + command only ever attaches a debug snapshot, and its own acceptance bar + is matching the oracle's axes, which do not include this one. + + Demoting here (not filtering the check out of the report) keeps the + written bundle, `doctor.summary`, `nextSteps` and the wire verdict all + reading the SAME check list -- a maintainer opening the file still sees + the real git/registry mismatch (the `detail`/`fix` text is untouched), + just at the severity this command can actually promise the oracle + agrees with. + """ + if check.name == "longPaths" and check.status == "fail": + return replace(check, status="warn") + return check + + +def _first_on_path(names: tuple[str, ...]) -> str | None: + return next((name for name in names if doctor_cmd.on_path(name) is not None), None) + + +def _extension_check(name: str, extension_id: str) -> doctor_cmd.Check: + """One VS Code extension-presence check, always `unknown` here -- port of + `doctor.rs::extension_check`'s `None` arm, which is the only arm the + standalone binary can reach (`observable: false`). Not `warn`: a warning + says something is wrong, and nothing is -- the question was not askable. + `unknown` is counted in no summary bucket and raises no issue, so it can + never move this command's exit code. + + The dash in `detail` is a real U+2014 EM DASH, not `--` (tan-cli#374 + finding 6): the oracle's own literal + (`crates/tan-core/src/debug/doctor.rs:132`) uses one, and this string is a + verbatim port of it -- unlike this file's own prose/comments, which use + `--` throughout, an emitted user-facing detail string copies its source of + truth byte for byte.""" + return doctor_cmd.Check( + name, + "unknown", + f"{extension_id}: unknown — the standalone tan binary cannot see " + "VS Code's installed extensions.", + ) + + +def _host_checks_from_doctor( + context: ResolvedDebugContext, project_arg: str | None, board_yaml_arg: str | None +) -> list[doctor_cmd.Check]: + """The host half of the bundled report, harvested BY NAME from + `doctor_cmd._collect` rather than re-probed here. + + The oracle appends these with `append_host_prerequisites` / + `append_host_environment`, both `pub(crate)` in `tan-cli` precisely so + `support_bundle.rs` can share `doctor.rs`'s copy. This port has the same + four checks -- `prerequisites_check`, `zephyr_sdk_host_check`, + `long_paths_check`, `home_path_check`, plus `bootstrapManifest` (see + `_HOST_CHECK_ORDER`) -- but their IO (manifest load, + effective-Python-floor arithmetic, registry read) is inline in `_collect`, + so calling `_collect` and picking the checks out is what keeps ONE copy of + each. Re-probing them here would be ~40 duplicated lines that drift the + first time `tan doctor` changes a verdict. + + KNOWN CEILING: `_collect` runs the WHOLE build/flash-readiness checklist + (west/JLink/SETOOLS probes included) to yield these five. It is the same + call this command already made before tan-cli#357, so the cost is + unchanged and the discarded checks are exactly the ones the oracle's + bundle never carried -- but if the probe cost ever matters, the fix is a + `doctor_cmd.host_environment_checks()` seam both callers share, not a + second copy of the probes here. + + `board_yaml` is passed only when explicitly given (`--board-yaml`) or the + file really exists, mirroring `doctor_cmd.doctor`'s own preprocessing rule + -- irrelevant to the five checks kept here, but passing a path that does + not exist would misreport `_collect`'s own `boardYaml`, and a future + reader harvesting one more name should not inherit a lie. + """ + board_yaml_for_doctor = ( + context.board_yaml_path + if (board_yaml_arg is not None or context.board_yaml_exists) + else None + ) + collected = doctor_cmd._collect( + context.sdk_root, + board_yaml=board_yaml_for_doctor, + project_scope=project_arg, + workspace_root=context.workspace_root, + sdk_tier=context.sdk_tier, + ) + by_name = {check.name: check for check in collected} + return [ + _demote_long_paths_fail(by_name[name]) + for name in _HOST_CHECK_ORDER + if name in by_name + ] + + +def _debug_doctor_report( + context: ResolvedDebugContext, + project_arg: str | None, + board_yaml_arg: str | None, + project_selected: bool, + target: str, + server: str, +) -> tuple[dict[str, Any], list[doctor_cmd.Check]]: + """The bundle's DEBUG-focused doctor section (tan-cli#357) -- port of + `tan_core::debug::doctor::build_doctor_report` plus `tan-cli`'s two host + appends. See the module docstring for what it replaced and why. + + No `serverCompatibility` check: the oracle's builder emits one and then + short-circuits, but `support_bundle.rs` refuses an unsupported pairing + with its own `support-bundle.server-compatibility` issue BEFORE building + any report (`_server_incompatible` here does the same), so that arm is + unreachable from this command. Verified against the oracle: + `--target-kind yocto-userspace --server jlink` writes no bundle at all. + + `workspaceRoot` has no fail arm for the same reason `inspect_cmd`'s + resolved-values rows have none: `resolve_debug_project_context` always + resolves a workspace root (cwd, or `--project` joined onto it), so Rust's + `is_present(&context.workspace_root)` is always true in this port's + construction. Left as a reported check because the oracle reports it and + a bundle reader looks for it. + """ + checks = [ + doctor_cmd.Check("workspaceRoot", "pass", context.workspace_root), + doctor_cmd.Check( + "sdkRoot", + "pass" if context.sdk_root else "fail", + context.sdk_root or "No alp-sdk checkout resolved.", + None + if context.sdk_root + else "Run `tan sdk switch ` or pass `--sdk-root `.", + ), + _board_yaml_check(context, project_selected), + *_target_checks(target, server), + *_host_checks_from_doctor(context, project_arg, board_yaml_arg), + ] + missing_prerequisites = next( + (c.missing for c in checks if c.name == "hostPrerequisites"), None + ) + report = { + "generatedAt": None, # filled by the caller, which already has the timestamp + "targetKind": target, + "server": server, + "summary": doctor_cmd.summarise(checks), + "checks": [c.as_dict() for c in checks], + "nextSteps": doctor_cmd.next_steps(checks), + "missingPrerequisites": missing_prerequisites, + } + return report, checks + + +def _board_yaml_check( + context: ResolvedDebugContext, project_selected: bool +) -> doctor_cmd.Check: + """Port of `doctor.rs::board_yaml_check` -- the DEBUG report's `boardYaml`, + which reports the resolved PATH as its detail (this port's build-checklist + `board_yaml_preflight_check` reports "board.yaml found" instead, and stays + where it is). + + A missing `board.yaml` is a hard failure only once the user NAMED a + project: with neither `--project` nor `--board-yaml` the path is a guess + at the working directory, and `tan bootstrap` sends every new customer to + run `tan doctor` from the SDK checkout root, which has no `board.yaml` and + needs none (#100). + """ + if context.board_yaml_exists: + return doctor_cmd.Check("boardYaml", "pass", context.board_yaml_path) + if project_selected: + return doctor_cmd.Check( + "boardYaml", + "fail", + context.board_yaml_path, + "Create board.yaml or pass `--board-yaml `.", + ) + return doctor_cmd.Check( + "boardYaml", + "warn", + f"no project selected -- no board.yaml at {context.board_yaml_path}", + "Select a project with `--project ` (or `--board-yaml `) to check one.", + ) + + +def _target_checks(target: str, server: str) -> list[doctor_cmd.Check]: + """The per-target-kind arm of `build_doctor_report`: one extension check + plus one tool check, both driven by `--target-kind`/`--server`. + + `lldb` always passes, with no `fix` (#131): `vadimcn.vscode-lldb` ships + its own complete LLDB inside the extension directory and never consults + PATH, so warning that none was found and telling the user to install one + is a remedy for a tool the product does not need. The resolved executable + is still REPORTED when present -- that is real information; only the + verdict and the advice were wrong. + """ + if target in (ZEPHYR_MCU, BAREMETAL_MCU): + found = _first_on_path(_RUNTIME_EXECUTABLES[server]) + return [ + _extension_check("cortexDebugExtension", "marus25.cortex-debug"), + doctor_cmd.Check( + f"{server}Backend", + "pass" if found else "warn", + found or f"No {server} executable was found on PATH.", + None if found else f"Install {server} and make sure it is on PATH.", + ), + ] + if target == YOCTO_USERSPACE: + gdb = _first_on_path(_RUNTIME_EXECUTABLES[GDBSERVER]) + return [ + _extension_check("cppToolsExtension", "ms-vscode.cpptools"), + doctor_cmd.Check( + "gdb", + "pass" if gdb else "warn", + gdb or "No local gdb executable was found on PATH.", + None if gdb else "Install gdb locally for symbolized remote debugging.", + ), + ] + lldb = _first_on_path(_RUNTIME_EXECUTABLES[SERVER_NONE]) + return [ + _extension_check("codeLLDBExtension", "vadimcn.vscode-lldb"), + doctor_cmd.Check( + "lldb", + "pass", + lldb or "vadimcn.vscode-lldb ships its own LLDB, so none is needed on PATH.", + ), + ] + + +def _doctor_issues(checks: list[doctor_cmd.Check]) -> list[Issue]: + """Warn/fail checks become `support-bundle.` issues -- port of + `support_bundle.rs::doctor_checks_to_issues`, with THIS port's own + `doctor_cmd.Check` shape (`name`/`status`/`detail`) instead of Rust's + `DoctorCheck`. `unknown` raises nothing: the question was not askable, + not a problem.""" + return [ + Issue( + f"support-bundle.{c.name}", + "error" if c.status == "fail" else "warning", + c.detail, + ) + for c in checks + if c.status in ("warn", "fail") + ] + + +def _write_bundle( + destination: str | None, + workspace_root: str, + generated_at: str, + payload: dict[str, Any], +) -> str: + """Write the redacted `payload` to a timestamped + `debug-support-bundle-*.json` file under `destination` (resolved against + cwd, like the oracle's `normalize_path(cwd.join(dest))`) or + `/.alp-support`. Returns the written path.""" + file_name = f"debug-support-bundle-{_timestamp_for_file(generated_at)}.json" + base_dir = ( + os.path.abspath(destination) + if destination is not None + else os.path.join(workspace_root, ".alp-support") + ) + output_path = os.path.join(base_dir, file_name) + os.makedirs(base_dir, exist_ok=True) + redacted = _redact(payload, _home_variants()) + with open(output_path, "w", encoding="utf-8", newline="") as handle: + json.dump(redacted, handle, indent=2) + handle.write("\n") + return output_path + + +# --------------------------------------------------------------------------- +# Envelope assembly +# --------------------------------------------------------------------------- + + +@dataclass +class _Outcome: + exit_code: ExitCode + data: dict[str, Any] + project: Project + sdk: SdkInfo | None + issues: list[Issue] + text: list[str] + #: Whether `--verbose` may append its "use --format json" hint under text + #: mode -- only the bundle-written path does (`support_bundle_text` in the + #: oracle); the three failure shapes (`internal_failure`/ + #: `server_incompatible`, plus this port's outer exception guard) have + #: their own fixed text and never grow a verbose-only line. Verified: `tan + #: support-bundle --target-kind yocto-userspace --server jlink --verbose` + #: prints only the one incompatibility line, no hint. + verbose_hint_eligible: bool = False + + +def _empty_data(generated_at: str, target: str, server: str) -> dict[str, Any]: + return { + "schemaVersion": DATA_SCHEMA_VERSION, + "generatedAt": generated_at, + "outputPath": "", + "targetKind": target, + "server": server, + "decisionCount": 0, + } + + +def _internal_failure( + generated_at: str, message: str, target: str, server: str, sdk: SdkInfo | None +) -> _Outcome: + return _Outcome( + exit_code=ExitCode.INTERNAL_FAILURE, + data=_empty_data(generated_at, target, server), + project=Project(root=None, board_yaml=None), + sdk=sdk, + issues=[Issue("support-bundle.internal-failure", "error", message)], + text=["support-bundle: internal failure", message], + ) + + +def _server_incompatible( + generated_at: str, target: str, server: str, sdk: SdkInfo | None +) -> _Outcome: + message = f"Server '{server}' is not supported for target '{target}'." + return _Outcome( + exit_code=ExitCode.DOCTOR_FAILURE, + data=_empty_data(generated_at, target, server), + project=Project(root=None, board_yaml=None), + sdk=sdk, + issues=[Issue("support-bundle.server-compatibility", "error", message)], + text=[f"support-bundle: server '{server}' is not supported for target '{target}'."], + ) + + +def _run( + *, + project_arg: str | None, + board_yaml_arg: str | None, + sdk_root_arg: str | None, + target_kind_arg: str | None, + server_arg: str | None, + path_arg: str | None, + target_arg: str | None, + destination_arg: str | None, +) -> _Outcome: + """The whole command as a pure-ish computation (project/SDK resolution and + the bundle-file WRITE are the only IO) returning one outcome. Mirrors + `debug_config_cmd._run`'s split: nothing here emits or exits, so the + caller's exception guard can wrap this call without swallowing + `typer.Exit`.""" + generated_at = generated_at_iso(millis=True) + context = resolve_debug_project_context(project_arg, board_yaml_arg, sdk_root_arg) + + try: + target = parse_target_kind(target_kind_arg) + server = parse_server_kind(server_arg) + except DebugConfigError as err: + return _internal_failure( + generated_at, str(err), NATIVE_HOST, SERVER_NONE, context.sdk + ) + + if not is_server_supported_for_target(target, server): + return _server_incompatible(generated_at, target, server, context.sdk) + + try: + decisions = _create_bundle_trace_decisions(context, target_arg, path_arg) + except TraceTargetError as err: + return _internal_failure(generated_at, str(err), target, server, context.sdk) + + # Port of `doctor.rs::project_selected`: `--project`/`--board-yaml` are the + # whole selection surface, so with neither given the resolved board.yaml + # path is a guess at the cwd, not a request. + project_selected = any( + (arg or "").strip() for arg in (project_arg, board_yaml_arg) + ) + doctor_report, checks = _debug_doctor_report( + context, project_arg, board_yaml_arg, project_selected, target, server + ) + doctor_report["generatedAt"] = generated_at + + notes = [ + f"targetKind={target}", + f"server={server}", + f"workspaceRoot={context.workspace_root}", + ] + payload = { + "schemaVersion": DATA_SCHEMA_VERSION, + "generatedAt": generated_at, + "inspect": { + "schemaVersion": DATA_SCHEMA_VERSION, + "generatedAt": generated_at, + # The oracle's `DebugWorkspaceContext`, key for key (tan-cli#357). + # `projectSelected`/`debuggerExtensions` are NOT fabricated: the + # first is derived from this invocation's own flags (see + # `project_selected` above, and `doctor.rs::project_selected`), the + # second is the standalone binary's own declared, self-describing + # state -- `observable: false` says in the file itself that the + # three flags were never probed. Omitting them cost the bundle the + # two facts that explain WHY its `boardYaml` and extension checks + # read the way they do, in the artifact a debug failure gets + # attached to. + "context": { + "generatedAt": generated_at, + "workspaceRoot": context.workspace_root, + "sdkRoot": context.sdk_root, + "boardYamlPath": context.board_yaml_path, + "westCwd": context.west_cwd, + "pythonBinary": context.python_binary, + "boardYamlExists": context.board_yaml_exists, + "projectSelected": project_selected, + "debuggerExtensions": STANDALONE_DEBUGGER_EXTENSIONS, + }, + "resolvedValues": collect_resolved_values(context), + }, + "trace": { + "schemaVersion": DATA_SCHEMA_VERSION, + "generatedAt": generated_at, + "workflow": "cli.support-bundle", + "decisions": decisions, + }, + "doctor": doctor_report, + "notes": notes, + } + + try: + output_path = _write_bundle(destination_arg, context.workspace_root, generated_at, payload) + except OSError as err: + return _internal_failure(generated_at, str(err), target, server, context.sdk) + + # tan-cli#357: the doctor summary IS this command's verdict, exactly as in + # the oracle (`if doctor.summary.fail > 0 { ExitCode::DoctorFailure }`). + # `exit_code_for` is `any(status == "fail")` over the same list, so it is + # that rule spelled in this port's vocabulary -- and it keeps the CLI-wide + # invariant `process exit code == envelope.exitCode == (not ok)` intact, + # which the previous hardcoded SUCCESS broke: automation saw `ok: true` + # and exit 0 beside error-severity issues in the same envelope. The bundle + # file is still written on the failing path -- it is what the user + # attaches, and a doctor failure is the reason they are attaching it. + issues = _doctor_issues(checks) + exit_code = doctor_cmd.exit_code_for(checks) + + data = { + "schemaVersion": DATA_SCHEMA_VERSION, + "generatedAt": generated_at, + "outputPath": output_path, + "targetKind": target, + "server": server, + "decisionCount": len(decisions), + } + text = [ + f"support-bundle: exported {output_path}", + f"support-bundle: trace decisions={len(decisions)}", + ] + return _Outcome( + exit_code=exit_code, + data=data, + project=context.project, + sdk=context.sdk, + issues=issues, + text=text, + verbose_hint_eligible=True, + ) + + +def support_bundle( + ctx: typer.Context, + project: str = typer.Option( + None, "--project", metavar="PATH", help="Project root (defaults to current directory)." + ), + target_kind: str = typer.Option( + None, + "--target-kind", + metavar="KIND", + help="Debug target class (zephyr-mcu, baremetal-mcu, yocto-userspace, native-host).", + ), + board_yaml: str = typer.Option( + None, + "--board-yaml", + metavar="PATH", + help="Explicit board.yaml path (overrides project resolution).", + ), + server: str = typer.Option( + None, + "--server", + metavar="SERVER", + help="Debug server backend (jlink, openocd, pyocd, gdbserver, none).", + ), + path: str = typer.Option( + None, "--path", metavar="PATH", help="Limit generation tracing to this config key path." + ), + sdk_root: str = typer.Option( + None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." + ), + destination: str = typer.Option( + None, + "--destination", + metavar="DESTINATION", + help="Output directory for the bundle (default: /.alp-support).", + ), + target: str = typer.Option( + None, + "--target", + metavar="EMIT", + help="Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf).", + ), + output_format: str = typer.Option( + None, "--format", metavar="FORMAT", help="Output format: text or json." + ), + verbose: bool = typer.Option( + False, "--verbose", help="Emit additional diagnostic detail." + ), + quiet: bool = typer.Option(False, "--quiet", hidden=True), + no_color: bool = typer.Option(False, "--no-color", hidden=True), + non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True), + ci: bool = typer.Option(False, "--ci", hidden=True), + all_targets: bool = typer.Option(False, "--all", hidden=True), +) -> None: + """Export a diagnostic support bundle (inspect + trace + doctor). + + `--all` is accepted and ignored, same as `tan trace`. `--quiet`/ + `--no-color`/`--non-interactive`/`--ci` are `global = true` clap options + `support_bundle.rs` never reads. + """ + del quiet, no_color, non_interactive, ci, all_targets + resolved_format = ( + output_format if output_format is not None else (ctx.obj or {}).get("format") or "text" + ) + if resolved_format not in ("text", "json"): + raise typer.BadParameter( + f"'{resolved_format}' (choose from 'text', 'json')", param_hint="--format" + ) + json_mode = resolved_format == "json" + + try: + outcome = _run( + project_arg=project, + board_yaml_arg=board_yaml, + sdk_root_arg=sdk_root, + target_kind_arg=target_kind, + server_arg=server, + path_arg=path, + target_arg=target, + destination_arg=destination, + ) + except Exception as err: # noqa: BLE001 -- see debug_config_cmd's identical guard + outcome = _internal_failure( + generated_at_iso(millis=True), + f"support-bundle failed unexpectedly: {err.__class__.__name__}: {err}", + NATIVE_HOST, + SERVER_NONE, + None, + ) + + if not json_mode: + for line in outcome.text: + typer.echo(line, err=True) + if verbose and outcome.verbose_hint_eligible: + typer.echo( + "support-bundle: include --format json for machine-readable envelopes.", + err=True, + ) + + if json_mode: + emit( + Envelope( + "support-bundle", + outcome.project, + outcome.data, + outcome.issues, + outcome.exit_code, + sdk=outcome.sdk, + ) + ) + raise typer.Exit(int(outcome.exit_code)) diff --git a/python/tan/commands/trace_cmd.py b/python/tan/commands/trace_cmd.py new file mode 100644 index 00000000..3df257bc --- /dev/null +++ b/python/tan/commands/trace_cmd.py @@ -0,0 +1,305 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan trace` -- report the generation decisions a build would make. + +Port of `crates/tan-cli/src/commands/trace.rs` + the `tan_core::loader`/ +`tan_core::debug::trace` pieces it reads. For each of the four per-core +build-config emit targets (`zephyr-conf`, `dts-overlay`, `cmake-args`, +`yocto-conf` -- [`BUILD_CONFIG_EMIT_MODES`]), report the loader command line + +output path a `tan build` slice would run. Requires a resolved SDK root and an +existing `board.yaml`; refuses otherwise (exit 2) rather than reporting decisions +for a project that cannot actually build. + +**Deliberately the narrower build-config set, not the full `tan generate` +surface (tan-cli#165 review finding 1).** `tan trace` reports "the generation +decisions a build would make" (this file's own name for the command); a build +only ever materialises these four -- `carrier-netlist`/`native-sim-overlay`/ +`west-libraries`/`hw-info-h`/`os-topology` are real `tan generate --target` +outputs a build never runs. [`BUILD_CONFIG_EMIT_MODES`] is this port's own +copy of `tan_core::loader::BUILD_CONFIG_EMIT_MODES`, not +`generate_cmd.ALL_EMIT_MODES`. + +**The output-path/command-line shape is measured, not guessed.** Rust's +`create_loader_plan` joins the workspace root onto the target's relative path +with exactly ONE `Path::join` call (never split component-wise), which on +Windows inserts a single native separator before an otherwise-untouched +forward-slash literal -- `C:/proj\\build/generated/alp.conf`, not +`C:\\proj\\build\\generated\\alp.conf`. Verified: `os.path.join(workspace_root, +"build/generated/alp.conf")` reproduces this byte-for-byte on Windows (and is a +no-op difference on POSIX, where `/` is already native), so [`_loader_plan`] +below uses a single `os.path.join` call per component the Rust also joins +separately (`sdk_root`, then `"scripts"`, then `"alp_project.py"` -- two +`.join()` calls, i.e. two inserted separators), rather than +`generate_cmd._output_path`'s fully-native, fully-split form -- the two +commands' oracle behaviour genuinely differs here, not just their code shape. + +`resolve_debug_project_context`/the six-row debug context are `inspect_cmd`'s; +this file imports them rather than re-deriving -- both commands, and +`support-bundle`, must read one context, or the same project could resolve +three different ways across the three commands. +""" + +from __future__ import annotations + +import os +from typing import Any + +import typer + +from tan.commands.inspect_cmd import resolve_debug_project_context +from tan.core.timestamp import generated_at_iso +from tan.envelope import Envelope, Issue, Project, emit +from tan.exit_codes import ExitCode + +#: `data.schemaVersion` for this command's payload. +DATA_SCHEMA_VERSION = "1" + +#: The output path (relative to the workspace root, `/`-separated -- an SDK +#: convention, not a host path) for each build-config emit target. Mirrors +#: `tan_core::loader::GENERATION_TARGET_CATALOG`'s four `BUILD_CONFIG_EMIT_ +#: MODES` entries verbatim; NOT re-derived from `generate_cmd._OUTPUT_ +#: RELATIVE_PATH` because [`_loader_plan`]'s join semantics differ from +#: `generate_cmd._output_path`'s (see the module docstring) even though the +#: four literal strings are identical today. +_OUTPUT_RELATIVE_PATH: dict[str, str] = { + "zephyr-conf": "build/generated/alp.conf", + "dts-overlay": "build/generated/alp.overlay", + "cmake-args": "build/generated/alp-cmake-args.txt", + "yocto-conf": "build/generated/alp-yocto.conf", +} + +#: The four per-core build-config targets a `tan build` slice actually +#: materialises -- the set `tan trace`/`tan support-bundle` enumerate by +#: default and validate an explicit `--target` against. Order is the oracle's +#: own catalog order, pinned: `data.decisions` reports in this sequence. +BUILD_CONFIG_EMIT_MODES: tuple[str, ...] = ( + "zephyr-conf", + "dts-overlay", + "cmake-args", + "yocto-conf", +) + + +class TraceTargetError(Exception): + """An unsupported `--target` value; `str()` is the user-facing message, + verbatim from the oracle.""" + + +def resolve_trace_targets(raw: str | None) -> tuple[str, ...]: + """`None` (no `--target`) -> all four, in catalog order. A known target -> + that one alone. `--all` is deliberately NOT a parameter here: the oracle's + `resolve_targets` never reads it either -- verified (`--target X --all` + still narrows to `X`; `--all` alone matches the bare-no-target default).""" + if raw is None: + return BUILD_CONFIG_EMIT_MODES + if raw in BUILD_CONFIG_EMIT_MODES: + return (raw,) + raise TraceTargetError( + f"Unsupported trace target '{raw}'. Allowed values: " + f"{', '.join(BUILD_CONFIG_EMIT_MODES)}." + ) + + +def _loader_plan( + workspace_root: str, sdk_root: str, board_yaml_path: str, python_binary: str, emit_target: str +) -> tuple[str, str]: + """`(output_path, command_line)` for one emit target -- port of + `tan_core::loader::create_loader_plan`. See the module docstring for why + the two `os.path.join` calls below must stay exactly this shape.""" + output_path = os.path.join(workspace_root, _OUTPUT_RELATIVE_PATH[emit_target]) + script_path = os.path.join(sdk_root, "scripts", "alp_project.py") + command_line = ( + f"{python_binary} {script_path} --input {board_yaml_path} --emit {emit_target} " + f"--output {output_path}" + ) + return output_path, command_line + + +def build_trace_decisions( + workspace_root: str, + sdk_root: str, + board_yaml_path: str, + python_binary: str, + targets: tuple[str, ...], + focus: str | None, +) -> list[dict[str, Any]]: + """One `Planned` decision per target, plus one more when `focus` (`--path`) + is set -- port of `trace.rs::run`'s decision-building loop. Shared with + `support_bundle_cmd`, whose bundled trace section is this exact list.""" + decisions: list[dict[str, Any]] = [] + for emit_target in targets: + output_path, command_line = _loader_plan( + workspace_root, sdk_root, board_yaml_path, python_binary, emit_target + ) + decisions.append( + { + "key": f"generation.target.{emit_target}", + "outcome": "planned", + "outputPath": output_path, + "detail": f"Would run: {command_line}", + } + ) + if focus is not None: + decisions.append( + { + "key": f"config.path.{focus}", + "outcome": "planned", + "detail": ( + "Path-level tracing is currently static and reports planning " + "context only." + ), + } + ) + return decisions + + +def _trace_text_lines(decisions: list[dict[str, Any]], quiet: bool) -> list[str]: + lines = [f"trace: decisions={len(decisions)}"] + if not quiet: + for d in decisions: + lines.append(f"[{d['outcome']}] {d['key']}: {d['detail']}") + return lines + + +def trace( + ctx: typer.Context, + path: str = typer.Option( + None, "--path", metavar="PATH", help="Limit tracing to this config key path." + ), + project: str = typer.Option( + None, "--project", metavar="PATH", help="Project root (defaults to current directory)." + ), + board_yaml: str = typer.Option( + None, + "--board-yaml", + metavar="PATH", + help="Explicit board.yaml path (overrides project resolution).", + ), + sdk_root: str = typer.Option( + None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root." + ), + target: str = typer.Option( + None, + "--target", + metavar="EMIT", + help="Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf).", + ), + output_format: str = typer.Option( + None, "--format", metavar="FORMAT", help="Output format: text or json." + ), + quiet: bool = typer.Option(False, "--quiet", help="Suppress non-essential output."), + verbose: bool = typer.Option(False, "--verbose", hidden=True), + no_color: bool = typer.Option(False, "--no-color", hidden=True), + non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True), + ci: bool = typer.Option(False, "--ci", hidden=True), + all_targets: bool = typer.Option(False, "--all", hidden=True), +) -> None: + """Trace the generation decisions a build would make. + + `--all` is accepted and ignored -- see [`resolve_trace_targets`]. The other + hidden flags are `global = true` clap options `trace.rs` never reads. + """ + del verbose, no_color, non_interactive, ci, all_targets + resolved_format = ( + output_format if output_format is not None else (ctx.obj or {}).get("format") or "text" + ) + if resolved_format not in ("text", "json"): + raise typer.BadParameter( + f"'{resolved_format}' (choose from 'text', 'json')", param_hint="--format" + ) + json_mode = resolved_format == "json" + + generated_at = generated_at_iso(millis=True) + focus = path + context = resolve_debug_project_context(project, board_yaml, sdk_root) + + def empty_data(target_value: str | None) -> dict[str, Any]: + return { + "schemaVersion": DATA_SCHEMA_VERSION, + "generatedAt": generated_at, + "workflow": "cli.trace", + "focusPath": focus, + "target": target_value, + "decisions": [], + } + + def fail(exit_code: ExitCode, code: str, message: str, data: dict, text_lines: list[str]) -> None: + issues = [Issue(f"trace.{code}", "error", message)] + if not json_mode: + for line in text_lines: + typer.echo(line, err=True) + if json_mode: + emit( + Envelope( + "trace", + Project(root=None, board_yaml=None), + data, + issues, + exit_code, + sdk=context.sdk, + ) + ) + raise typer.Exit(int(exit_code)) + + if context.sdk_root is None: + fail( + ExitCode.VALIDATION_FAILURE, + "sdk-root-unresolved", + "alp-sdk root is unresolved. Use --sdk-root, pin one with `tan sdk switch " + "`, or place the project near an alp-sdk checkout.", + empty_data(target), + ["trace: alp-sdk root is unresolved."], + ) + + if not context.board_yaml_exists: + fail( + ExitCode.VALIDATION_FAILURE, + "board-yaml-missing", + "board.yaml path could not be resolved or the file does not exist.", + empty_data(target), + ["trace: board.yaml path is unresolved or missing."], + ) + + try: + targets = resolve_trace_targets(target) + except TraceTargetError as err: + # Mirrors the oracle's catch-block data: `target: null`, unlike the two + # guard failures above (which echo the raw `--target` back). + fail( + ExitCode.INTERNAL_FAILURE, + "internal-failure", + str(err), + empty_data(None), + ["trace: internal failure", str(err)], + ) + return # pragma: no cover -- fail() always raises typer.Exit + + decisions = build_trace_decisions( + context.workspace_root, + context.sdk_root, + context.board_yaml_path, + context.python_binary, + targets, + focus, + ) + resolved_target = targets[0] if len(targets) == 1 else None + + data = { + "schemaVersion": DATA_SCHEMA_VERSION, + "generatedAt": generated_at, + "workflow": "cli.trace", + "focusPath": focus, + "target": resolved_target, + "decisions": decisions, + } + + if not json_mode: + for line in _trace_text_lines(decisions, quiet): + typer.echo(line, err=True) + + if json_mode: + emit( + Envelope( + "trace", context.project, data, [], ExitCode.SUCCESS, sdk=context.sdk + ) + ) + raise typer.Exit(int(ExitCode.SUCCESS)) diff --git a/python/tan/commands/validate_cmd.py b/python/tan/commands/validate_cmd.py index fe476e3c..5a62c455 100644 --- a/python/tan/commands/validate_cmd.py +++ b/python/tan/commands/validate_cmd.py @@ -11,24 +11,48 @@ ``scripts/validate_board_yaml.py``, spawned as a subprocess. tan does not reimplement alp-sdk's schema: the SDK owns ``metadata/schemas/`` and ADR-0017's doctrine is to consume what exists. Not ported yet, and it says - so -- at exit 1 (``RuntimeFailure``), not exit 5. + so -- at exit 2 (``ValidationFailure``), not exit 1 or exit 5. - **Exit 1 here is a DEFERRAL, not a match to the oracle.** An earlier revision - of this docstring claimed it matched; that claim was never measured and was - wrong. Measured directly, running ``target/debug/tan.exe`` (tan 0.4.1-dev) - with ``--format json``: + **tan-cli#262 (v0.6.0, TAKEN): a missing verdict is the VALIDATOR's + problem, not a tan crash -- exit 2, not exit 1.** An earlier revision of + this docstring left that an open question ("the genuine v0.6.0 decision, + tracked in tan-cli#262"); the maintainer has now decided it. Measured + directly, running ``target/debug/tan.exe`` (tan 0.4.1-dev) with + ``--format json``: - empty directory -> exit **2**, ``validate.board-yaml-missing`` - ``board.yaml`` present, no SDK root -> exit **2**, ``validate.sdk-root-unresolved`` Exit 1 is reachable in the oracle only AFTER a validator actually spawns and - returns an unexpected status. So the oracle draws a line this port does not - yet: pre-spawn guards at 2, post-spawn failure at 1. The ``board.yaml`` - -missing guard below is now aligned with it. The remaining divergence -- - ``board.yaml`` present but no SDK, where the oracle says 2 - ``sdk-root-unresolved`` and this port says 1 ``spawn-not-implemented`` -- is - the genuine v0.6.0 decision, tracked in tan-cli#262. + returns an exit status the resolver cannot map to a named outcome + (``Outcome::Failed``, see the table below) -- the oracle's own + ``validation_outcome_exit_code`` (`crates/tan-cli/src/commands/validate.rs: + 54-62`) sends that ONE case to ``RuntimeFailure`` (1) while every other + non-clean outcome already goes to ``ValidationFailure`` (2). alp-sdk-vscode + renders exit 2 as severity "warning" and exit 1 as "error", so that one + oracle case alone painted a genuinely-failing project red in the IDE -- + indistinguishable from `tan` itself crashing. + + This port deliberately does NOT mirror that one oracle case. "The validator + could not produce a verdict" is still the validator's verdict, in every + shape it takes here -- GENERALLY, not just for the ``spawn-not-implemented`` + stub below. Today that stub is the only reachable instance and now emits + ``ExitCode.VALIDATION_FAILURE`` (2). When the real spawn path lands (also + tan-cli#262) and this port reaches the equivalent of the oracle's + ``Outcome::Failed`` (a spawned validator subprocess whose exit status is + outside the 0-3 range the resolver names -- see the table below), it too + MUST emit ``ExitCode.VALIDATION_FAILURE`` (2), not ``RuntimeFailure`` (1) -- + do not "fix" that back to oracle parity; that parity is the bug #262 fixes. + A genuine `tan`-side crash is unaffected by this decision and keeps its own + exit code: a file that could not be read, or an unexpected exception that + escaped tan's own code, stays ``ExitCode.INTERNAL_FAILURE`` (5, the two + cases already implemented below); a future spawn-launch I/O error + specifically (the subprocess could not even be started) would be + ``ExitCode.RUNTIME_FAILURE`` (1) -- the "generic runtime failure (e.g. I/O + or subprocess error)" `crates/tan-cli/src/exit.rs` itself names that code + for. Only the validator's-verdict exit code moves; tan's own crash exit + codes do not. Exit 5 is wrong on every one of these paths regardless: reporting "not ported yet" as ``InternalFailure`` would tell CI/the extension this is a tan crash, @@ -61,7 +85,13 @@ particular not exit 2 or 3, which have their own named outcomes. A reader must not infer "any nonzero -> failed" from this docstring. That is a DIFFERENT failure shape from this port's: the oracle spawned and got back - nonsense, this port never spawns at all. + nonsense, this port never spawns at all. The ``rc`` column above is the + ORACLE's, measured, and stays 1 for the ``failed`` row -- that is a fact + about ``target/debug/tan.exe``, not a decision, and must not be edited to + "fix" the table. This port's OWN rc for the same row is 2, per tan-cli#262 + above -- a divergence recorded in prose here rather than in the table + because the table is a record of what was measured, not of this port's + choices. Reusing ``validate.failed`` here would conflate "we attempted validation and the subprocess misbehaved" with "this code path does not exist yet" under one string, which is a worse signal for the same reason the exit-code @@ -72,7 +102,8 @@ this port. It becomes dead code the moment the real spawn path lands (tan-cli#262) and this branch is deleted in favour of actually spawning, at which point the resulting failures naturally become ``validate.failed`` - like the oracle's. + like the oracle's -- KEEPING exit 2, per the decision above, not reverting + to the oracle's exit 1 for that row. **A wrong-shaped board.yaml is the USER's problem, not a tan crash.** The Rust carries a comment earned the hard way: routing a malformed file through @@ -101,6 +132,7 @@ import typer +from tan.core.global_flags import accept_global_flags from tan.envelope import Envelope, Issue, Project, emit from tan.exit_codes import ExitCode from tan.version import TAN_VERSION @@ -429,7 +461,17 @@ def _emit( typer.echo(json.dumps(_sarif_document(issues, board_path), indent=2)) else: stream = typer.get_text_stream("stderr") - if issues: + if len(issues) == 1 and issues[0].code == "validate.board-yaml-missing": + # tan-cli#350: this is not a VALIDATION failure -- there is no + # board.yaml to validate, so nothing was checked and found + # wrong. Every other non-clean outcome below still says + # "validate: validation failure"; only this one issue code gets + # its own verdict wording. `issues[0].message` (shared with + # `--format json`'s `issues[].message`) already names where tan + # looked and the remedy -- see the guard above. + stream.write("validate: no board.yaml to validate\n") + stream.write(f"{issues[0].message}\n") + elif issues: stream.write("validate: validation failure\n") for issue in issues: stream.write(f"{issue.message}\n") @@ -494,23 +536,49 @@ def fail(code: str, message: str, exit_code: ExitCode) -> None: # that short-circuits above this check would answer "not ported yet" to # a question the oracle answers "your board.yaml is missing", in the one # case a brand-new user hits first. Cheap to keep compatible; keep it. + # + # tan-cli#350 (DELIBERATE divergence -- the oracle is byte-identical + # here, down to exit code and message): the oracle's own wording, + # "board.yaml path could not be resolved or the file does not + # exist.", names no remedy and, worse, is fronted in text mode by + # "validate: validation failure" -- a VERDICT that implies something + # was checked and found wrong. Nothing was validated; there is no + # board.yaml to validate. This is the state every user is in before + # `tan init`, and the old wording sent them looking for a defect in a + # file that does not exist. The message below names WHERE tan looked + # and the two remedies every sibling guard names for its own missing + # input (`build` names `--sdk-root`, `doctor` names `tan init` / + # `--board-yaml ` for this exact guard -- see + # `doctor_cmd.py`'s `_board_yaml_check`). The exit code (2) and issue + # CODE (`validate.board-yaml-missing`) are UNCHANGED: a + # found-but-invalid board.yaml still exits 2 as + # `validate.schema-violation` and still prints "validate: validation + # failure" below -- the issue code is how a machine consumer (or a + # human reading `--format json`) tells the two apart, since the exit + # code alone does not. fail( "board-yaml-missing", - "board.yaml path could not be resolved or the file does not exist.", + f"no board.yaml found at {board_path} -- run `tan init` to create " + "one, or pass --board-yaml to point at an existing file.", ExitCode.VALIDATION_FAILURE, ) return if not offline: # The SDK owns metadata/schemas/; tan does not reimplement it. The spawn - # path is NOT ported, so this reports a deferral. See the module - # docstring for why exit 1 (RuntimeFailure) rather than exit 5, and for - # the one divergence from the oracle it knowingly keeps (tan-cli#262). + # path is NOT ported, so this reports a deferral -- "no verdict is + # available" is still the VALIDATOR's problem, not a tan crash. + # tan-cli#262 (v0.6.0, TAKEN): ExitCode.VALIDATION_FAILURE (2), never + # ExitCode.RUNTIME_FAILURE (1), deliberately diverging from the + # oracle's `Outcome::Failed -> RuntimeFailure` mapping + # (`crates/tan-cli/src/commands/validate.rs:60`). See the module + # docstring for the full reasoning -- do NOT "fix" this back to + # RuntimeFailure for oracle parity; that parity is the bug #262 fixes. fail( "spawn-not-implemented", "the full (spawn) validator is not ported yet -- run with --offline, " "or use the SDK's scripts/validate_board_yaml.py directly.", - ExitCode.RUNTIME_FAILURE, + ExitCode.VALIDATION_FAILURE, ) return @@ -559,3 +627,10 @@ def fail(code: str, message: str, exit_code: ExitCode) -> None: issues=issues, exit_code=exit_code, ) + + +# tan-cli#261: adds the seven oracle `GlobalArgs` flags this command was +# still missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--quiet`/ +# `--target`/`--verbose`) on top of `--board-yaml`, already declared and read +# above; see `tan.core.global_flags`. +validate = accept_global_flags(validate) diff --git a/python/tan/core/bootstrap.py b/python/tan/core/bootstrap.py index e9f88b56..acb3b17b 100644 --- a/python/tan/core/bootstrap.py +++ b/python/tan/core/bootstrap.py @@ -854,12 +854,60 @@ def hint_line(tool: str, install: dict[str, str]) -> str: return f" {tool} -> install `{tool}` and put it on PATH" +#: tan-cli#355, added as a SECOND line on the refusals below -- the oracle's own +#: first line is left byte-identical. See `posix_refusal` for why. +_DOCTOR_FIX_HINT = "Or run `tan doctor --build --fix` to install them from the SDK's manifest." + +#: tan-cli#370. The line above is TRUE only where the manifest's own install +#: commands need no elevation. alp-sdk's `prerequisites.install.linux` is six +#: `sudo apt-get install -y ...` entries, and `doctor --build --fix` REFUSES to +#: spawn anything whose first word is `sudo` (`doctor_cmd.fix_needs_sudo_check`, +#: `doctor.fix-needs-sudo`) -- deliberately, because under `--format json` this +#: process's stdio is captured end to end and a password prompt would hang +#: forever rather than fail loudly. So on Linux `--fix` installs NOTHING; it +#: prints the exact command per tool. Promising an install there trades one +#: wrong expectation for another, which is the very thing #355 set out to stop, +#: and it does so on the host most customers are on. +#: +#: Keyed on the COMMANDS, not on the platform: macOS is POSIX and its `brew +#: install ...` needs no elevation, so it earns the plain wording, and Windows +#: `winget` (user-scope) does too. Reading the commands is also what keeps this +#: correct against a manifest nobody here has seen. +_DOCTOR_FIX_HINT_NEEDS_ELEVATION = ( + "Or run `tan doctor --build --fix`: it prints the exact command for each " + "tool from the SDK's manifest, and runs the ones needing no elevation " + "(tan never spawns `sudo` itself)." +) + + +def _doctor_fix_hint(missing: list[str], install: dict[str, str]) -> str: + """Which of the two hints above is true for THESE tools on THIS host. + + Only the commands reachable for the MISSING tools are considered: a `sudo` + entry belonging to some tool that is already present says nothing about + what `--fix` will do in this run. A tool the manifest has no command for + cannot need elevation either -- it contributes generic advice, not a spawn. + """ + return ( + _DOCTOR_FIX_HINT_NEEDS_ELEVATION + if any(install.get(tool, "").split(maxsplit=1)[:1] == ["sudo"] for tool in missing) + else _DOCTOR_FIX_HINT + ) + + def windows_refusal(missing: list[str], install: dict[str, str]) -> PrereqFailure: """`bootstrap.ps1`'s `$Prereqs` loop: header, one `hint_line` each, the reopen-PowerShell tail.""" lines = ["Missing required tools:"] lines.extend(hint_line(tool, install) for tool in missing) lines.append("Install the tools above (then reopen PowerShell) and re-run.") + # tan-cli#355: same gap as the POSIX refusal -- name the installer tan ships. + # The Windows wording is tan's own (it already carries per-tool hints the + # POSIX one may not), so this is an addition, not a divergence. + # tan-cli#370: which of the two hints is true depends on the commands, not + # on the platform -- Windows `winget` is user-scope today, but the manifest + # is alp-sdk's to change and this reads what it actually says. + lines.append(_doctor_fix_hint(missing, install)) return PrereqFailure( "prerequisites-missing", tuple(lines), _structured_missing(missing, install) ) @@ -869,10 +917,35 @@ def posix_refusal(missing: list[str], install: dict[str, str]) -> PrereqFailure: """`bootstrap.sh`'s one line: the tool names and nothing else -- TWO spaces before "Install". The oracle prints no per-tool commands and neither may this; alp-sdk#959 changed what the STRUCTURED half carries, not what a POSIX - user reads.""" + user reads. + + **tan-cli#355 adds a SECOND line, and only a second line.** The oracle's + first line is still emitted byte for byte, two spaces and all, and a parity + test pins it so the match stays provable. What is added is the sentence + naming `tan doctor --build --fix`. + + A DELIBERATE divergence from the oracle, recorded here so nobody restores + the silence. "The oracle prints no per-tool commands and neither may this" + was right when tan had no installer of its own; tan-cli#91 changed that + fact, and `doctor --build --fix` now runs exactly the manifest-owned + install commands these missing tools need. Measured in a pristine + `ubuntu:24.04`, a first-time customer got + + Missing required tools: cmake ninja xz wget. Install them and re-run. + + and nothing else, while the command that would install them sat one + subcommand away, unmentioned. Withholding a remedy tan HAS, to match an + oracle that never had one, is parity serving nobody. + + The per-tool commands themselves still stay OUT of the prose -- that half of + the original constraint holds, and they remain where alp-sdk#959 put them, + in the structured payload's `{tool, command}` pairs.""" return PrereqFailure( "prerequisites-missing", - (f"Missing required tools: {' '.join(missing)}. Install them and re-run.",), + ( + f"Missing required tools: {' '.join(missing)}. Install them and re-run.", + _doctor_fix_hint(missing, install), + ), _structured_missing(missing, install), ) diff --git a/python/tan/core/consent.py b/python/tan/core/consent.py new file mode 100644 index 00000000..27eff7e1 --- /dev/null +++ b/python/tan/core/consent.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The one implementation of `GlobalArgs::can_prompt()` — the gate every +command must pass before it prompts a human or mutates the host. + +Ported from the Rust oracle's `GlobalArgs::can_prompt()`, whose own +`--non-interactive` help text states the rule the port must honour verbatim: + + Never prompt. A command with a documented default takes it (`tan init` + scaffolds `zephyr-app` into `.`); one without fails instead of asking + (`tan scaffold` needs `--name`). **The same rule applies unasked when + stdin or stderr is not a terminal — piped, redirected, or a CI runner.** + +That last sentence is the half a re-derivation keeps dropping, and dropping it +is not cosmetic. Before this module existed the check was written out by hand +in four places and **one of them was wrong**: `doctor --fix` (tan-cli#91) +tested only `not non_interactive and not ci and not json_mode` and omitted both +`isatty()` calls, so a CI runner that redirected its output but did not happen +to pass `--ci` got **unattended host mutation** — demonstrated live with fully +captured pipes, where `tan doctor --fix` spawned four real `winget install` +runs (`Git.Git`, `Kitware.CMake`, `Python.Python.3.12`, `Ninja-build.Ninja`) +with nobody watching. A redirected stdio stream is the single most common shape +of an automated run, so the omitted condition was the one that mattered most. + +Hence one function, imported. Duplicating a consent gate means every future +copy is another chance to drop the clause that makes it a consent gate at all. + +**Why BOTH `stdin` and `stderr`, not just `stdin`.** A prompt is a +question-and-answer pair and each half needs its own real terminal: `stdin` +carries the answer, and `stderr` — never `stdout`, which belongs to the +envelope — carries the question. `tan doctor --fix < /dev/null` has no way to +receive consent; `tan doctor --fix 2>log` has no way to ask for it, and would +block on a question the user never saw. Requiring both is what makes "the user +actually agreed to this" true rather than merely likely. + +**Not `stdout`.** Under `--format json` stdout is a single parsed envelope, and +`| jq` is a normal, fully-interactive way to run tan. Testing `stdout.isatty()` +would refuse consent in a session where the human is sitting right there. The +`json_mode` flag already covers the case that actually matters. +""" +from __future__ import annotations + +import sys + + +def can_prompt(*, non_interactive: bool, ci: bool, json_mode: bool) -> bool: + """Whether this invocation may prompt the user, or take any other action + that needs a human's live consent (installing a toolchain, overwriting a + file, relocating a checkout). + + All five conditions must hold. The three flags are the caller's explicit + "do not ask me" signals; the two `isatty()` calls are the same rule applied + **unasked**, for the automated runs that never thought to pass a flag. + + A command with a documented default takes it when this returns `False`; one + without a default fails instead of asking. + """ + return ( + not non_interactive + and not ci + and not json_mode + and sys.stdin.isatty() + and sys.stderr.isatty() + ) diff --git a/python/tan/core/debug_launch.py b/python/tan/core/debug_launch.py index 4460ec78..70c01900 100644 --- a/python/tan/core/debug_launch.py +++ b/python/tan/core/debug_launch.py @@ -107,18 +107,72 @@ def is_server_supported_for_target(target: str, server: str) -> bool: return server in server_choices_for_target(target) +#: The v0.3.1 default ``preLaunchTask``, restored per tan-cli#138. Keyed by +#: TARGET ALONE, never by server: ``crates/tan-core/src/debug_launch.rs`` +#: hardcoded the identical literal in every one of ``ZephyrMcu``'s and +#: ``BaremetalMcu``'s server-branched arms before tan-cli#85 made the key +#: opt-in, so J-Link/OpenOCD/pyOCD all shared one string per target, not one +#: each. ``--pre-launch-task`` overrides a target's default; an EXPLICIT empty +#: string opts out of a ``preLaunchTask`` key entirely -- see +#: :func:`create_launch_draft`. +#: +#: **``YOCTO_USERSPACE`` is deliberately absent** -- three of the four targets +#: get a default, not four. v0.3.1 did hardcode +#: ``"alp: deploy and start gdbserver"`` for it, but restoring THAT one would +#: re-break what alp-sdk-vscode#406 deliberately fixed. That repo's +#: ``preLaunchTaskFor`` (``src/tasks/service.ts``) maps only the three build +#: kinds, and states why verbatim: +#: +#: yocto-userspace deliberately gets NOTHING. The only task registered for +#: it is the "deploy and start gdbserver" placeholder, which exits 1 by +#: design (``vscodeAdapter.ts``) because the extension cannot deploy or +#: start a remote gdbserver. Naming it would put VS Code's "the +#: preLaunchTask terminated with exit code 1 -- Debug Anyway / Show +#: Errors" dialog in front of EVERY F5, including one where the customer +#: has already copied the binary across, started gdbserver by hand and +#: filled in ``miDebuggerServerAddress`` -- the setup that works. +#: +#: So tan-cli#138 and tan-cli#321 pull opposite ways here, and only three of +#: the four labels are safe to restore. For the build kinds the extension DOES +#: register a working task, and omitting the key is precisely what left its +#: provider contribution dead. For yocto-userspace the only task that exists +#: fails by design, so naming it would degrade the one workflow that currently +#: succeeds. A user with their own deploy task still passes +#: ``--pre-launch-task`` explicitly. +DEFAULT_PRE_LAUNCH_TASK: dict[str, str] = { + ZEPHYR_MCU: "alp: build active target", + BAREMETAL_MCU: "alp: build baremetal target", + NATIVE_HOST: "alp: build native_sim target", +} + + def create_launch_draft( target: str, server: str, pre_launch_task: str | None ) -> dict[str, Any]: """The VS Code launch configuration draft for a target/server (TS ``createDebugProfile`` -> ``debugProfileToLaunchDraft``). - ``pre_launch_task`` is emitted ONLY when the caller supplies one. Every - draft used to carry a hardcoded ``preLaunchTask`` that **nothing in any of - the three repos defines** -- no ``tasks.json``, no ``TaskProvider`` - registration. VS Code resolves ``preLaunchTask`` before launching, fails to - find the task, and aborts pre-launch, so the session never starts: a - launch.json that reads perfectly and cannot run. + ``pre_launch_task`` has three states, not two (tan-cli#138): + + * ``None`` -- the flag was not passed. Takes this target's restored + v0.3.1 default from :data:`DEFAULT_PRE_LAUNCH_TASK`. Every draft used to + carry this hardcoded string unconditionally; tan-cli#85 made the key + opt-in because **nothing in any of the three repos defined the task + it named** -- no ``tasks.json``, no ``TaskProvider`` registration -- + and VS Code resolves ``preLaunchTask`` before launching, fails to find + the task, and aborts pre-launch, so the session never started: a + launch.json that reads perfectly and cannot run. alp-sdk-vscode has + since registered the THREE build labels as real, working tasks, so the + default is restored for those three targets. The fourth label exists + only as a placeholder that exits 1 by design, and + ``preLaunchTaskFor`` maps three of four kinds for that reason -- see + :data:`DEFAULT_PRE_LAUNCH_TASK` for the full quotation. A target with + no entry there behaves exactly as it did before this restoration. + * ``""`` (an explicitly empty string) -- opts OUT of a ``preLaunchTask`` + key entirely, even though a default now exists for this target. The + only way left to reach the trailing ``del`` below. + * Anything else -- emitted verbatim, overriding the default. Unchanged + from before this restoration. """ if not is_server_supported_for_target(target, server): raise DebugConfigError( @@ -126,6 +180,11 @@ def create_launch_draft( ) label = _SERVER_LABELS[server] + if pre_launch_task is None: + pre_launch_task = DEFAULT_PRE_LAUNCH_TASK.get(target) + elif pre_launch_task == "": + pre_launch_task = None + if target == ZEPHYR_MCU: name = f"Alp: Zephyr Debug ({label})" common = { @@ -260,6 +319,13 @@ class LaunchResolution: #: SDK ships no SVD file, and alp-sdk#948's vendor-redistribution licence #: question may mean it never does. svd: str | None = None + #: `host:port` for a yocto-userspace draft's `miDebuggerServerAddress`. + #: Produced ONLY by `tan debug-config --gdbserver-address` (tan-cli#321): + #: this is a runtime property of the DEPLOYED board -- which host it is + #: reachable at and which port its gdbserver is listening on -- which no + #: build, and no SDK-published metadata, can ever resolve. `None` unless + #: the caller passed one. + gdbserver_address: str | None = None def fill_debug_probe_identity_gaps( @@ -324,6 +390,11 @@ def apply_launch_resolution(draft: dict[str, Any], resolution: LaunchResolution) draft["targetId"] = resolution.target_id if resolution.config_files and "configFiles" in draft: draft["configFiles"] = list(resolution.config_files) + if resolution.gdbserver_address is not None and "miDebuggerServerAddress" in draft: + # tan-cli#321: the ONLY source of this field's resolution -- see + # `LaunchResolution.gdbserver_address`'s own docstring for why nothing + # else (build or SDK metadata) can ever fill it. + draft["miDebuggerServerAddress"] = resolution.gdbserver_address if resolution.gdb_path is not None: # cppdbg spells it `miDebuggerPath` and already carries the key; # cortex-debug's `gdbPath` is additive. diff --git a/python/tan/core/flash_plan.py b/python/tan/core/flash_plan.py index e4313f19..d01f7bd3 100644 --- a/python/tan/core/flash_plan.py +++ b/python/tan/core/flash_plan.py @@ -1,1541 +1,1721 @@ -# SPDX-License-Identifier: Apache-2.0 -"""Pure planning for ``tan flash`` -- the decision + argv-building half. - -Port of ``crates/tan-core/src/flash/`` (``mod.rs`` / ``args.rs`` / -``builders.rs`` / ``registry.rs`` / ``storage.rs``) plus the manifest reader in -``crates/tan-core/src/system_manifest.rs``. Every string, argv, filter and -per-backend command shape lives here with NO IO; the subprocess / filesystem / -temp-file half is ``tan.commands.flash_cmd``. - -The flow mirrors ``alp_flash.dispatch`` + ``_flash_entry``: walk the manifest's -``boot_order`` (or the sorted slice ``core_id``s when empty), map each step to -its slice, append the helper MCUs after, then dispatch each entry's -``flash_method`` to a backend plan-builder. - -**Strict ``flash_args`` reading.** A whole ``flash_args`` that is not a mapping -(the AEN701 helper's ``flash_args: TBD`` string) reads as an empty map -- but a -sub-key that IS present is read STRICTLY: every behaviour-affecting bool/int -(``erase``, ``use_openocd``, ``reset``, ``base``, ``baud``, ...) goes through a -``_checked`` accessor that hard-errors on a wrong-type scalar rather than -silently defaulting, since a wrong flash is worse than a refused one. Do not -reintroduce a tolerant bool/int reader here. - -**No hardware facts (I-26 / ADR-0017).** Nothing in this module names a SKU, an -address, a pin, an I2C address, a probe serial or a vendor branch. Every such -value arrives in ``flash_args``, passed through from alp-sdk ``metadata/``. The -ONE exception is inherited verbatim from the Rust oracle and flagged at its -definition (``_DEFAULT_JLINK_DEVICE``); do not add a second. -""" -from __future__ import annotations - -import os -from dataclasses import dataclass -from typing import Any, Callable - -from tan.core.pending import PENDING_PLACEHOLDER as PENDING_SENTINEL, is_pending_placeholder - -#: The system-manifest schema major this command consumes. A different value is -#: REFUSED rather than read as if it were v1 -- mirrors -#: `system_manifest.rs::SYSTEM_MANIFEST_SCHEMA_VERSION`. -SYSTEM_MANIFEST_SCHEMA_VERSION = 1 - -_DEFAULT_BASE = "0x08000000" -#: INHERITED HARDWARE FACT, not a new one. `builders.rs:15`'s -#: `DEFAULT_JLINK_DEVICE`. This is a part number in tan, which ADR-0017 / I-26 -#: forbids, and it is already shipped in the Rust binary -- changing or dropping -#: it here would make the port disagree with the oracle on every `swd_probe` -#: entry whose `flash_args` omits `jlink_device`. Kept byte-identical and -#: quarantined to this one constant; the correct fix is for the SoM preset to -#: always supply `flash_args.jlink_device` (E1M-V2N101 already does not), after -#: which this default becomes unreachable and can be deleted on BOTH sides. -_DEFAULT_JLINK_DEVICE = "GD32G553MEY7TR" -_DEFAULT_JLINK_SPEED = 4000 -_JLINK_BINARIES = ("JLinkExe", "JLink") - - -class ManifestError(Exception): - """`build/system-manifest.yaml` could not be consumed. `message` is the - human text; the caller pairs it with `flash.manifest-invalid`.""" - - -class FlashPlanError(Exception): - """A backend refused to build a plan -- the `Err(String)` arm of every - `plan_*` builder in `builders.rs`/`storage.rs`. The message is reported - verbatim as the entry's `message`.""" - - -# ── manifest reading ──────────────────────────────────────────────────────── - - -@dataclass(frozen=True) -class Slice: - """One per-core image from the manifest's `slices[]`. Tolerant reader: only - the fields `tan flash` consumes are modeled and unknown additive-v1 keys are - ignored, per the stability policy in `system_manifest.rs`.""" - - core_id: str - os: str - status: str = "" - output_artefact: str | None = None - flash_method: str | None = None - flash_args: Any = None - - -@dataclass(frozen=True) -class HelperMcu: - """One on-module helper MCU from `helper_mcus[]`.""" - - name: str - firmware_path: str | None = None - flash_method: str | None = None - flash_args: Any = None - update_channel: str | None = None - - -@dataclass(frozen=True) -class Manifest: - sku: str = "" - slices: tuple[Slice, ...] = () - helper_mcus: tuple[HelperMcu, ...] = () - boot_order: tuple[Any, ...] = () - - -def _opt_str(raw: Any) -> str | None: - """A manifest string field, or `None`. A non-string scalar reads as absent - rather than being coerced: `serde` would have failed the whole document, and - `str(4)` here would silently invent a path/method name.""" - return raw if isinstance(raw, str) else None - - -def parse_system_manifest(text: str) -> Manifest: - """Parse + version-guard a `system-manifest.yaml` document. - - Raises `ManifestError` for: PyYAML unavailable, malformed YAML, a non-mapping - document, a `schema_version` that is not 1, or a `slices[]`/`helper_mcus[]` - entry missing a field the Rust struct declares non-`Option` (`core_id`/`os` - for a slice, `name`/`chip` for a helper) -- serde fails the ENTIRE parse in - that last case, so a partial read here would flash against a manifest the - oracle rejects. - - tan ships no YAML dependency of its own (`python/pyproject.toml`), so PyYAML - is imported lazily. Its absence is FATAL here, unlike in `debug-config` - where the manifest is a best-effort enrichment: `flash` cannot pick a target - or an artefact without it, and silently flashing nothing would be the worse - outcome. - """ - try: - import yaml # noqa: PLC0415 (optional at runtime, by design) - except ImportError as err: - raise ManifestError( - "reading a system-manifest needs PyYAML, which is not importable " - f"({err}); install it (`pip install pyyaml`) or run tan from a " - "bootstrapped workspace" - ) from err - try: - doc = yaml.safe_load(text) - except Exception as err: # noqa: BLE001 -- the SDK's output, not ours - raise ManifestError(f"system-manifest is not valid YAML: {err}") from err - if doc is None or not isinstance(doc, dict): - raise ManifestError( - "system-manifest is not valid YAML: expected a mapping at the " - f"document root, got {type(doc).__name__}" - ) - version = doc.get("schema_version") - if version != SYSTEM_MANIFEST_SCHEMA_VERSION: - raise ManifestError( - f"unsupported system-manifest schema_version {version} (this CLI " - f"consumes v{SYSTEM_MANIFEST_SCHEMA_VERSION}); upgrade the CLI or " - "the SDK so the versions match" - ) - - hw_info = doc.get("hw_info") - sku = "" - if isinstance(hw_info, dict) and isinstance(hw_info.get("sku"), str): - sku = hw_info["sku"] - - slices: list[Slice] = [] - for raw in _seq(doc.get("slices")): - if not isinstance(raw, dict): - raise ManifestError("system-manifest is not valid YAML: slices[] entry is not a mapping") - core_id, os_name = raw.get("core_id"), raw.get("os") - if not isinstance(core_id, str) or not isinstance(os_name, str): - raise ManifestError( - "system-manifest is not valid YAML: every slices[] entry needs a " - "string `core_id` and `os`" - ) - slices.append( - Slice( - core_id=core_id, - os=os_name, - status=raw["status"] if isinstance(raw.get("status"), str) else "", - output_artefact=_opt_str(raw.get("output_artefact")), - flash_method=_opt_str(raw.get("flash_method")), - flash_args=raw.get("flash_args"), - ) - ) - - helpers: list[HelperMcu] = [] - for raw in _seq(doc.get("helper_mcus")): - if not isinstance(raw, dict): - raise ManifestError( - "system-manifest is not valid YAML: helper_mcus[] entry is not a mapping" - ) - name, chip = raw.get("name"), raw.get("chip") - if not isinstance(name, str) or not isinstance(chip, str): - raise ManifestError( - "system-manifest is not valid YAML: every helper_mcus[] entry needs " - "a string `name` and `chip`" - ) - helpers.append( - HelperMcu( - name=name, - firmware_path=_opt_str(raw.get("firmware_path")), - flash_method=_opt_str(raw.get("flash_method")), - flash_args=raw.get("flash_args"), - update_channel=_opt_str(raw.get("update_channel")), - ) - ) - - return Manifest( - sku=sku, - slices=tuple(slices), - helper_mcus=tuple(helpers), - boot_order=tuple(_seq(doc.get("boot_order"))), - ) - - -def _seq(raw: Any) -> list[Any]: - """A manifest list field. `#[serde(default)]` means a missing key is an - empty list; a key present with a NON-list value is a shape error serde would - reject, so it is not silently treated as empty here either -- `[]` is - returned only for genuinely absent/null.""" - if raw is None: - return [] - if not isinstance(raw, list): - raise ManifestError( - f"system-manifest is not valid YAML: expected a sequence, got {type(raw).__name__}" - ) - return raw - - -# ── target selection ──────────────────────────────────────────────────────── - -SLICE = "slice" -HELPER = "helper" - - -@dataclass(frozen=True) -class FlashTarget: - """One manifest entry selected for flashing, in dispatch order.""" - - kind: str - id: str - flash_method: str | None - flash_args: Any - output_artefact: str | None = None - firmware_path: str | None = None - update_channel: str | None = None - - -@dataclass(frozen=True) -class TargetPlan: - targets: tuple[FlashTarget, ...] - warnings: tuple[str, ...] - refused: tuple[str, ...] - #: The subset of "status not ok" refusals whose slice `status` is - #: `"skipped"` -- i.e. `tan build` itself declined to build this slice - #: under `executionPolicy.missingTool`/`.nullCommand` (a host with no - #: `bitbake`, say). That was a policy decision already made and reported - #: at build time; `flash` refusing to flash a never-built artefact is - #: still correct (there is nothing to flash), but it must not ALSO read - #: as a flash failure for a slice the customer's manifest already - #: explained away. `refused` (a `"failed"`/`"pending"`/other status) is - #: the opposite: `tan build` tried and the slice is broken or was never - #: reconciled, which must keep failing `tan flash`. Callers surface this - #: bucket as a WARNING and must not fold it into a failure count -- see - #: `refused` for the error-severity, exit-code-affecting bucket. - #: - #: **DIVERGES from the shipped Rust oracle.** `crates/tan-core/src/ - #: flash/mod.rs`'s `plan_flash_targets` has no `refused_skipped` bucket at - #: all -- a `status: skipped` slice/helper lands in the ONE `refused` list - #: alongside `failed`/`pending`/anything else non-`ok`, and the CLI seeds - #: `failed` from `refused.len()` before the dispatch loop even runs, so the - #: oracle FAILS the run on a `status: skipped` slice exactly like any other - #: bad status. This split (and the caller's warning-only, exit-0 treatment - #: when something else DID flash) is a deliberate product improvement on - #: top of the port, not a porting bug -- but the caller (`tan.commands. - #: flash_cmd.flash`) MUST still fail the run when every match was a - #: `refused_skipped` entry and nothing flashed (`flash.nothing-flashed`), - #: or this bucket reintroduces the exact silent-success class `refused` - #: exists to prevent, just inverted. `tests/parity/ - #: test_flash_oracle_parity.py` deliberately carries no `status: skipped` - #: case for this reason -- the two implementations disagree there by - #: design and an oracle diff would only fail. - refused_skipped: tuple[str, ...] = () - - - -def plan_flash_targets( - manifest: Manifest, core: str | None = None, helper: str | None = None -) -> TargetPlan: - """Build the ordered flash target list + any `boot_order` warnings/refusals. - - - Empty `boot_order`: one step per slice `core_id`, sorted ascending. - - Non-empty `boot_order`: walked in order; a step naming a `core_id` not in - `slices` is dropped and surfaced as a warning. - - A slice whose `status` is not `ok` is REFUSED, not flashed and not silently - dropped: `overlay_run_results` PRESERVES the plan-time `output_artefact` - when a later run has no artefact for that core, so a run-1 success followed - by a run-2 failure/skip leaves run-1's elf on disk under a manifest - reporting a broken slice. Flashing that stale elf and silently dropping the - slice are the same silent-failure class. A `status: skipped` refusal is - split into `refused_skipped` rather than `refused`: `tan build` already - decided (via `executionPolicy`) that this slice was not supposed to build - on this host -- e.g. no `bitbake` on an MCU-only checkout -- and that is - not a flash failure, it is `tan flash` agreeing with a decision already - made and reported. A genuinely broken slice (`status: failed`, or any - other non-`ok`/non-`skipped` value) stays in `refused`. - - Helpers always come AFTER all slices. - - `core` flashes only that slice and skips every helper; `helper` skips every - slice and flashes only that helper. - - Callers MUST surface both `refused` and `refused_skipped`: those entries - never enter `targets`, so a caller that only reports `targets`/`warnings` - would show a clean run while a stale/never-built artefact stayed unflashed. - Only `refused` (not `refused_skipped`) may fail the overall run -- see - `TargetPlan.refused_skipped`. - """ - targets: list[FlashTarget] = [] - warnings: list[str] = [] - refused: list[str] = [] - refused_skipped: list[str] = [] - - def find_slice(cid: str) -> Slice | None: - # Non-empty core_id only, matching the Python dict-comprehension guard - # `alp_flash` used and the `!s.core_id.is_empty()` filter in Rust. - for s in manifest.slices: - if s.core_id and s.core_id == cid: - return s - return None - - if not manifest.boot_order: - steps = sorted(s.core_id for s in manifest.slices if s.core_id) - else: - steps = [] - for step in manifest.boot_order: - if not isinstance(step, dict): - continue - named = step.get("core") - if isinstance(named, str) and named: - steps.append(named) - - # A slice present in `slices` but never named by a `boot_order` step used to - # be dropped with NO warning at all -- a heterogeneous system silently - # flashed a strict subset of its cores and reported success. Only warn on the - # unfiltered default run: `--core` deliberately narrows the slice set and - # `--helper` deliberately suppresses every slice. - if manifest.boot_order and helper is None and core is None: - for s in manifest.slices: - if s.core_id and s.core_id not in steps: - warnings.append(f"flash: slice '{s.core_id}' has no boot_order entry; not flashed") - - if helper is None: - for cid in steps: - if core is not None and cid != core: - continue - found = find_slice(cid) - if found is None: - warnings.append( - f"flash: boot_order references core '{cid}' not in slices; skipping" - ) - continue - if not slice_should_flash(found.status): - if found.status == "skipped": - # A policy decision `tan build` already made and reported - # (`executionPolicy.missingTool`/`.nullCommand`), not a - # broken build -- "stale, rebuild it" is wrong on both - # counts: nothing was ever built, so nothing is stale, and - # rebuilding ON THIS HOST hits the same policy skip again. - refused_skipped.append( - f"flash: slice '{found.core_id}' build status is 'skipped' -- " - "tan build already declined to build it under executionPolicy " - "(a missing tool or a null command on this host); there is " - "nothing to flash. Rebuilding on this same host will skip it " - "again -- it needs a host where that tool resolves." - ) - else: - refused.append( - f"flash: slice '{found.core_id}' build status is " - f"'{found.status}' (not 'ok'); refusing to flash its artefact " - "-- it may be stale from a previous successful build. " - "Rebuild it first." - ) - continue - targets.append( - FlashTarget( - kind=SLICE, - id=found.core_id, - flash_method=found.flash_method, - flash_args=found.flash_args, - output_artefact=found.output_artefact, - ) - ) - - if core is None: - for h in manifest.helper_mcus: - if not h.name: - continue - if helper is not None and h.name != helper: - continue - targets.append( - FlashTarget( - kind=HELPER, - id=h.name, - flash_method=h.flash_method, - flash_args=h.flash_args, - firmware_path=h.firmware_path, - update_channel=h.update_channel, - ) - ) - - - return TargetPlan( - tuple(targets), tuple(warnings), tuple(refused), tuple(refused_skipped) - ) - - -def slice_should_flash(status: str) -> bool: - """A slice is flashed iff it built successfully. `image_bundle.rs:: - slice_should_bundle` -- the same one-line predicate, shared on purpose so - `flash` and `image` can never disagree about which artefacts are real.""" - return status == "ok" - - -# ── path helpers ──────────────────────────────────────────────────────────── - - -def is_rust_absolute(path: str) -> bool: - """`Path::is_absolute()` semantics, NOT `os.path.isabs`. - - On Windows Rust requires BOTH a prefix (drive/UNC) and a root, so a - rooted-but-driveless `/dev/sdb` or `\\x` is RELATIVE and `base.join(p)` - discards part of `base`. `os.path.isabs("/dev/sdb")` answered True on - Windows until Python 3.13 and False from 3.13 on -- so reaching for it would - make artefact resolution differ from the oracle AND differ between two - supported interpreters on the same host. - """ - if os.name == "nt": - drive, rest = os.path.splitdrive(path) - return bool(drive) and rest[:1] in ("\\", "/") - return path.startswith("/") - - -def resolve_artefact_path( - artefact: str, - build_root: str, - sdk_root: str | None, - is_file: Callable[[str], bool], -) -> str: - """Resolve a manifest artefact string to a path. Absolute strings pass - through; a relative string tries `build_root/artefact`, then - `sdk_root/artefact`, then west's NESTED `build_root/build/artefact`, and - falls back to the `build_root` candidate. `is_file` is injected to keep this - pure. - - The first two candidates and the fallback are `flash/mod.rs:: - resolve_artefact_path` verbatim. The third is the consumer half of **I-18**: - the planner emits `west build` with NO `-d`, so west's tree lands at - `/build/` while the plan's `artifacts` block still reports - `/zephyr/zephyr.elf`. Rust reconciles that at manifest-WRITE time - (`build/execute/manifest.rs::resolve_zephyr_artefact`, tan's only writer of - `output_artefact`, which stores the nested ABSOLUTE path); this port's - `build` does not write the manifest yet, so an artefact string that still - carries the planner's un-nested spelling would resolve to a file that is not - there and fail the entry. Probed LAST and only when the oracle's own - candidates all miss a real file, so it can never change a resolution the - oracle already makes -- an absolute artefact never reaches it at all. - """ - if is_rust_absolute(artefact): - return artefact - cand_build = os.path.join(build_root, artefact) - if sdk_root is None: - return cand_build - if is_file(cand_build): - return cand_build - cand_sdk = os.path.join(sdk_root, artefact) - if is_file(cand_sdk): - return cand_sdk - cand_nested = os.path.join(build_root, "build", artefact) - if is_file(cand_nested): - return cand_nested - return cand_build - - -# ── flash_args accessors ──────────────────────────────────────────────────── - - -def _fa_get(value: Any, key: str) -> Any: - """A `flash_args` sub-key, or `None` when `flash_args` is not a mapping. - Mirrors `args.rs::fa_get`'s `v.as_mapping()?`: the AEN701 helper's - `flash_args: TBD` string reads as an empty map, not an error.""" - if not isinstance(value, dict): - return None - return value.get(key) - - -def _fa_has_key(value: Any, key: str) -> bool: - """Whether `flash_args` is a mapping that carries `key` AT ALL -- - independent of what it resolves to. `_fa_get`/`fa_str_checked` collapse a - present-but-null value and a genuinely-absent key to the same `None`, - which is right for every OPTIONAL field but wrong for one that must - distinguish "not selected" from "selected with a malformed value" (see - `slot0_load_address` in `plan_alif_mram_jlink`, and `expect_dpidr` / - `jlink_device` in `flow_d_preflight_script`).""" - return isinstance(value, dict) and key in value - - -def _yaml_debug(value: Any) -> str: - """`serde_yaml::Value`'s `{:?}` rendering, so the strict accessors' refusal - messages match the oracle byte for byte (`String("true")`, `Number(1)`, - `Bool(true)`, `Sequence [Number(1), Number(2)]`). Verified against the - shipped binary; the messages ship to the customer and to the extension's - issue list, and a diff harness that has to special-case them stops being - able to prove anything about the rest of the envelope.""" - if value is None: - return "Null" - if isinstance(value, bool): - return f"Bool({'true' if value else 'false'})" - if isinstance(value, str): - return f'String("{value}")' - if isinstance(value, (int, float)): - return f"Number({value})" - if isinstance(value, list): - return "Sequence [" + ", ".join(_yaml_debug(v) for v in value) + "]" - if isinstance(value, dict): - body = ", ".join(f"{_yaml_debug(k)}: {_yaml_debug(v)}" for k, v in value.items()) - return "Mapping {" + body + "}" - return f"String(\"{value}\")" - - -def fa_str(value: Any, key: str) -> str | None: - """A non-empty string sub-key; `None` when absent, empty, or non-string.""" - raw = _fa_get(value, key) - if isinstance(raw, str) and raw: - return raw - return None - - -def fa_bool_checked(value: Any, key: str) -> bool | None: - """Strict bool accessor for every behaviour-affecting `flash_args` bool - (`reset`, `erase`, `use_openocd`, `use_pyocd`, `confirm`, ...). - - A quoted `"false"` is NOT a bool, and a tolerant reader would read it as - absent, apply the caller's default and program the OPPOSITE of what was - written. `None` only for genuinely absent/null; any other shape raises.""" - raw = _fa_get(value, key) - if raw is None: - return None - if isinstance(raw, bool): - return raw - raise FlashPlanError( - f"flash_args.{key} must be a bare boolean (true/false, unquoted; got " - f"{_yaml_debug(raw)}) -- refusing to silently fall back to a default -- " - "this plans a real flash write." - ) - - -def fa_int_checked(value: Any, key: str) -> int | None: - """Strict int accessor (`jlink_speed`, `baud`, `jobs`, `speed`). - - `0`-means-absent semantics are preserved from the oracle: an explicit `0` - yields `None`, i.e. "use the default". `bool` is checked BEFORE `int` -- - Python's `True` IS an `int`, so an unguarded `isinstance(raw, int)` would - accept `jobs: true` and emit `-j 1`.""" - raw = _fa_get(value, key) - if raw is None: - return None - if isinstance(raw, bool): - raise FlashPlanError(_int_refusal(key, raw)) - if isinstance(raw, int): - return raw if raw != 0 else None - raise FlashPlanError(_int_refusal(key, raw)) - - -def _int_refusal(key: str, raw: Any) -> str: - return ( - f"flash_args.{key} must be a bare number (unquoted; got {_yaml_debug(raw)}) " - "-- refusing to silently fall back to a default -- this plans a real " - "flash write." - ) - - -def fa_str_checked(value: Any, key: str, as_hex_address: bool) -> str | None: - """Strict string accessor for fields where falling back to a baked-in default - is dangerous -- a flash base address, an OpenOCD interface/target name that - gets interpolated into a spawned command. - - `fa_str` treats ANY non-string value -- including the bare YAML integer an - unquoted `base: 0x08000000` resolves to -- as "absent", so the caller - silently substitutes the default and programs real silicon at the wrong - address with no warning. This returns `None` only for genuinely - absent/null/empty, round-trips a bare non-negative number back into a string - (hex for an address field, decimal otherwise), and refuses every other shape. - - A NEGATIVE number is refused outright rather than formatted: Rust's - `n as u64` sign-extends `-8` into `0xFFFFFFFFFFFFFFF8`, which - `validate_address` (a pure charset check) then ACCEPTS as a plausible - address and the J-Link/OpenOCD command interpolates verbatim. - """ - raw = _fa_get(value, key) - if raw is None: - return None - if isinstance(raw, bool): - # Guarded before the int arm for the same reason as `fa_int_checked`: - # `True` is an `int`, and `base: true` must not resolve to `0x00000001`. - raise FlashPlanError(_str_refusal(key, raw)) - if isinstance(raw, str): - return raw or None - if isinstance(raw, int): - if raw < 0: - raise FlashPlanError( - f"flash_args.{key} = {raw} is negative; refusing to interpret it as " - "an address/count -- this plans a real flash write." - ) - return f"0x{raw:08X}" if as_hex_address else str(raw) - raise FlashPlanError(_str_refusal(key, raw)) - - -def _str_refusal(key: str, raw: Any) -> str: - return ( - f"flash_args.{key} must be a quoted string (got {_yaml_debug(raw)}); " - "refusing to silently fall back to a default -- this plans a real flash write." - ) - - -def is_pending(value: Any) -> bool: - """Whether a manifest SCALAR is the SDK's unfilled-field sentinel. - - **The one definition for the whole flash path (#222).** Every guard in this - area used to test for EMPTY, and empty is the one thing a `TBD` placeholder - is not -- so an unfilled field behaved exactly like a filled one, and - whether that ended in a loud refusal or a spawned flasher came down to - whether the particular consumer happened to validate against a closed set. - `flash_method: TBD` hit the backend registry and failed safely; - `output_artefact`/`firmware_path: TBD` hit nothing at all, resolved to - `/TBD` and reached a real J-Link write. Route every new - manifest-derived field through THIS, never through a fresh `== "TBD"`. - - Trimmed before comparing -- a YAML `device: " TBD "` is the same unfilled - field -- but deliberately NOT case-folded and NOT a substring test: - `TBD-1234-XYZ` is a plausible part number and `flash_args.build_dir: - /opt/TBDtool/x` a plausible path, and refusing either would block a - legitimate flash. `tbd` lowercase is not the sentinel alp-sdk emits; - widening to it means widening the SDK's convention first, in one place, - not here. - - The comparison is the single `tan.core.pending.is_pending_placeholder` - definition (#276): the neutral module with no flash- or image-bundle - machinery behind it, so `tan.core.size` (and `pinmux`, once ported) can - read the same rule without pulling flash internals in. `PENDING_SENTINEL` - stays the name this module exports -- `flash_cmd` and the flash tests - already spell it that way -- but it is now an alias for - `pending.PENDING_PLACEHOLDER`, not a second definition. `tan image`'s own - `image_bundle.PENDING_SENTINEL` is still a separate `"TBD"` literal; - pointing it at the same module too is a follow-up outside flash_plan.py. - """ - return is_pending_placeholder(value) - - -def flash_args_has_tbd(value: Any) -> bool: - """Whether `flash_args` carries an unresolved `TBD` ANYWHERE -- a bare `TBD` - scalar, or a mapping/sequence value that trims to `TBD`. - - Deliberately broader than a single-key check: a `TBD` anywhere means the - entry is not finalised yet under the SDK's pending-placeholder convention. - Do not narrow this back to a set of known keys. Recurses into mapping VALUES - and sequence elements, not mapping keys: every accessor here reads by a - known key name, so a key literally named `TBD` selects nothing and cannot - reach an argv. - - This covers `flash_args` ONLY. The sibling artefact fields - (`output_artefact`/`firmware_path`) are NOT part of `flash_args` and are - guarded separately at the point of use -- see `is_pending`. - """ - if isinstance(value, str): - return is_pending(value) - if isinstance(value, dict): - return any(flash_args_has_tbd(v) for v in value.values()) - if isinstance(value, list): - return any(flash_args_has_tbd(v) for v in value) - return False - - -# ── validators ────────────────────────────────────────────────────────────── - - -def validate_identifier(text: str, field_name: str) -> None: - """Reject anything that is not a plain identifier, or a `/`-separated path - of plain identifier segments. - - `interface`/`target` are interpolated verbatim into an OpenOCD - `-f .cfg` path and a `-c` Tcl command string, so an unrestricted value - is a path-traversal + Tcl-injection primitive into a process routinely run - with device-flashing privileges. Multi-segment is allowed because OpenOCD - ships interface configs in subdirectories (`ftdi/olimex-arm-usb-ocd-h`). - - Rust composes `path_guard::is_plain_relative` with a per-segment charset - check. The charset alone is EQUIVALENT here and is what is implemented: the - only shapes `is_plain_relative` adds are absolute/rooted/drive-prefixed and - `.`/`..`, and every one of those carries a character (`/` leading -> an empty - segment, `:`, `\\`, `.`) the charset already rejects. Cross-checked against - the oracle on `a;b`, `../x`, `/x`, `\\x`, `C:/x`, `a//b`, `.`. - """ - segments = text.split("/") - ok = bool(text) and all( - seg and all(c.isascii() and (c.isalnum() or c in "-_") for c in seg) for seg in segments - ) - if not ok: - raise FlashPlanError( - f"flash_args.{field_name} = {_quoted(text)} is not a plain identifier or " - "'/'-separated path of plain identifiers (letters, digits, '-', '_' per " - "segment) -- refusing to interpolate it into a spawned command / OpenOCD " - "Tcl script." - ) - - -def validate_address(text: str, field_name: str) -> None: - """A flash base address must be purely hex digits, with an optional `0x`/`0X`. - - `base` is interpolated verbatim into a J-Link Commander script LINE and an - OpenOCD `-c` Tcl command string -- both line/command-oriented interpreters, - so a newline (or `;`, `[`, `]`) inside `base` runs arbitrary extra commands - against whatever silicon is attached. - """ - digits = text - for prefix in ("0x", "0X"): - if digits.startswith(prefix): - digits = digits[len(prefix) :] - break - if not digits or not all(c in "0123456789abcdefABCDEF" for c in digits): - raise FlashPlanError( - f"flash_args.{field_name} = {_quoted(text)} is not a plain hex/decimal " - "address -- refusing to interpolate it into a J-Link/OpenOCD command." - ) - - -#: `char::escape_debug`'s named escapes, which is what Rust's `{:?}` for a -#: `&str` emits. Applied in ONE pass -- escaping `\\` up front and then -#: re-scanning would revisit the backslashes it just added. -_DEBUG_ESCAPES = { - "\\": "\\\\", - '"': '\\"', - "\t": "\\t", - "\r": "\\r", - "\n": "\\n", -} - - -def _quoted(text: str) -> str: - """Rust's `{s:?}` for a `&str`. - - Not just `"` and `\\`: Rust escapes control characters too, so a `base` - containing a real newline renders as `"0x8000\\n r"` -- ONE line -- and not - as a refusal message split across two. These messages are exactly the ones - reporting an injection attempt (`validate_address`/`validate_identifier` - exist to catch a newline smuggled into a J-Link Commander script line), so a - diagnostic that itself breaks across lines is the worst possible rendering: - a reader sees a truncated message and the offending bytes on their own line. - Caught by the oracle diff, not by review. - """ - rendered = [ - _DEBUG_ESCAPES.get(char) - or (char if char.isprintable() else f"\\u{{{ord(char):x}}}") - for char in text - ] - return '"' + "".join(rendered) + '"' - - -def is_raw_bin(artefact: str) -> bool: - """Whether an artefact is a raw binary (needs an explicit load address), as - opposed to ELF/HEX which carry their own. Passing a load offset for a - non-`.bin` artefact shifts every section by that offset and writes outside - the intended flash region.""" - return os.path.splitext(artefact)[1].lower() == ".bin" - - -# ── the plan + backend registry ───────────────────────────────────────────── - - -@dataclass(frozen=True) -class FlashPlan: - """A built flash plan: the argv, the success message, whether it is - planning-only (never spawns real device IO), and -- for the J-Link path -- - the Commander script the caller must materialise to a temp file.""" - - argv: tuple[str, ...] - ok_message: str - planning_only: bool = False - jlink_script: str | None = None - - -@dataclass(frozen=True) -class BackendMeta: - """A registered backend: the tool-gate `requires` list + its plan-builder.""" - - requires: tuple[str, ...] - build: Callable[["FlashInputs", Callable[[str], bool]], FlashPlan] - - -@dataclass(frozen=True) -class FlashInputs: - """Everything a backend plan-builder consumes. Injected by the CLI layer.""" - - artefact: str - flash_args: Any - core_id: str - sku: str - dry_run: bool = False - #: The env half of the confirm gate (`ALP_FLASH_FORCE=1`). The per-entry - #: `flash_args.confirm` is OR-ed in by the gated builders, so the effective - #: gate is `flash_args.confirm OR ALP_FLASH_FORCE=1`. - force_confirm: bool = False - - -def backend_for(method: str) -> BackendMeta | None: - """Resolve a `flash_method` string to its backend metadata, or `None`.""" - return _REGISTRY.get(method) - - -def registry_keys() -> list[str]: - """The registered method names, sorted -- for the "Available: ..." error.""" - return sorted(_REGISTRY) - - -def registry_keys_debug() -> str: - """`{:?}` of a `Vec<&str>`, for the unknown-method message.""" - return _str_list_debug(registry_keys()) - - -def _str_list_debug(items) -> str: - return "[" + ", ".join(_quoted(i) for i in items) + "]" - - -# ── swd_probe ─────────────────────────────────────────────────────────────── - - -def jlink_commander_script(artefact: str, base: str, do_reset: bool) -> str: - """The J-Link Commander script: reset/halt, load (`loadbin`+base for `.bin`, - else `loadfile`), optional reset-and-go, quit-close.""" - lines = ["r", "halt"] - if is_raw_bin(artefact): - lines.append(f"loadbin {artefact}, {base}") - else: - lines.append(f"loadfile {artefact}") - if do_reset: - lines += ["r", "g"] - lines.append("qc") - return "\n".join(lines) + "\n" - - -def plan_swd_probe(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: - """`swd_probe`: J-Link (primary) / OpenOCD / pyOCD.""" - fa = inp.flash_args - base = fa_str_checked(fa, "base", True) - if base is not None: - validate_address(base, "base") - else: - base = _DEFAULT_BASE - do_reset = _default(fa_bool_checked(fa, "reset"), True) - force_pyocd = _default(fa_bool_checked(fa, "use_pyocd"), False) - force_openocd = _default(fa_bool_checked(fa, "use_openocd"), False) - core = inp.core_id - is_bin = is_raw_bin(inp.artefact) - - # `--dry-run` is documented to bypass the required-tool PATH gate entirely; - # without the `inp.dry_run` bypass here this inner probe hard-failed a dry - # run on any box without a probe tool installed, making `--dry-run` - # host-dependent instead of a pure preview. - jlink: str | None = None - if not (force_pyocd or force_openocd): - if inp.dry_run: - jlink = _JLINK_BINARIES[0] - else: - jlink = next((n for n in _JLINK_BINARIES if which(n)), None) - if jlink is not None: - device = _default(fa_str_checked(fa, "jlink_device", False), _DEFAULT_JLINK_DEVICE) - speed = _default(fa_int_checked(fa, "jlink_speed"), _DEFAULT_JLINK_SPEED) - return FlashPlan( - argv=( - jlink, "-device", device, "-if", "SWD", "-speed", str(speed), - "-AutoConnect", "1", "-ExitOnError", "1", "-NoGui", "1", - "-CommanderScript", - ), - ok_message=( - f"swd_probe[{core}]: GD32G553 flashed via J-Link ({device}) @ {base}" - ), - jlink_script=jlink_commander_script(inp.artefact, base, do_reset), - ) - - interface = _default(fa_str_checked(fa, "interface", False), "") - target = _default(fa_str_checked(fa, "target", False), "") - if not interface or not target: - raise FlashPlanError( - "swd_probe: flash_args.interface and flash_args.target are required for " - "the openocd/pyocd path (e.g. interface=cmsis-dap, target=gd32g553) -- " - "or install SEGGER J-Link for the primary path." - ) - validate_identifier(interface, "interface") - validate_identifier(target, "target") - openocd = not force_pyocd and (inp.dry_run or which("openocd")) - pyocd = not force_openocd and (inp.dry_run or which("pyocd")) - if openocd: - program = f"program {inp.artefact} verify" - if do_reset: - program += " reset" - # `base` is a load OFFSET, meaningful only for a raw `.bin`; ELF/HEX - # carry their own addresses and OpenOCD's `program` proc adds a trailing - # address to them, so passing it unconditionally shifts every section. - program += f" exit {base}" if is_bin else " exit" - argv = ( - "openocd", "-f", f"interface/{interface}.cfg", - "-f", f"target/{target}.cfg", "-c", program, - ) - elif pyocd: - parts = ["pyocd", "flash", "--target", target] - # pyOCD's --base-address is documented binary-only; passing it for an - # ELF/HEX is meaningless at best and a wrong-address write at worst. - if is_bin: - parts += ["--base-address", base] - parts.append(inp.artefact) - argv = tuple(parts) - else: - raise FlashPlanError( - "swd_probe: no flash tool found -- install SEGGER J-Link (preferred), " - "or `openocd`, or `pyocd`." - ) - return FlashPlan(argv=argv, ok_message=f"swd_probe[{core}]: GD32G553 flashed @ {base}") - - -def _default(value, fallback): - """`Option::unwrap_or`. Spelled out because `value or fallback` is WRONG for - every falsy-but-present value this module reads -- `reset: false`, - `jlink_speed` legitimately absent-as-0, `interface: ""`.""" - return fallback if value is None else value - - -# ── zephyr_west_flash / baremetal_cmake_flash ─────────────────────────────── - - -def zephyr_build_dir(artefact: str) -> str: - """The Zephyr build dir derived from the artefact: `parent.parent` when the - artefact sits directly in a `zephyr/` subdirectory, else `parent`. - - Checks the PARENT DIRECTORY NAME, never the artefact's basename: an - MCUboot-signed (`zephyr.signed.hex`) or sysbuild (`merged.hex`) output still - lands in `/zephyr/` under a different name, and a basename - allowlist sent those one directory too deep -- `west flash --build-dir - ` then failed with no CMakeCache.txt there. - - `os.path.dirname`, not `Path.parent`: it slices the string and preserves - whatever separators the joined path already mixes (a native `build_root` + - a `/`-authored manifest artefact), exactly as Rust's `Path::parent` does. - """ - parent = os.path.dirname(artefact) - if os.path.basename(parent).lower() == "zephyr": - return os.path.dirname(parent) - return parent - - -def plan_zephyr_west_flash(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: - """`zephyr_west_flash`: `west flash --build-dir [--runner ] [--erase] - [--hex-file ]`. - - `runner` is OPTIONAL -- when absent, `--runner` is omitted and `west flash` - falls back to the board.cmake default runner (on an AEN board that is - `alif_flash`, i.e. Flow A over the SE-UART). - """ - del which # this backend probes nothing - fa = inp.flash_args - runner = fa_str(fa, "runner") - build_dir = _default(fa_str(fa, "build_dir"), zephyr_build_dir(inp.artefact)) - argv = ["west", "flash", "--build-dir", build_dir] - if runner is not None: - argv += ["--runner", runner] - if _default(fa_bool_checked(fa, "erase"), False): - argv.append("--erase") - hex_file = fa_str(fa, "hex_file") - if hex_file is not None: - argv += ["--hex-file", hex_file] - return FlashPlan( - argv=tuple(argv), - ok_message=( - f"zephyr_west_flash[{inp.core_id}]: programmed via " - f"{runner if runner is not None else 'board-default runner'}" - ), - ) - - -def plan_baremetal_cmake_flash(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: - """`baremetal_cmake_flash`: `cmake --build --target [--config ] [-j N]`.""" - del which - fa = inp.flash_args - build_dir = _default(fa_str(fa, "build_dir"), os.path.dirname(inp.artefact)) - target = _default(fa_str(fa, "target"), "flash") - argv = ["cmake", "--build", build_dir, "--target", target] - config = fa_str(fa, "config") - if config is not None: - argv += ["--config", config] - jobs = fa_int_checked(fa, "jobs") - if jobs is not None: - argv += ["-j", str(jobs)] - return FlashPlan( - argv=tuple(argv), - ok_message=f"baremetal_cmake_flash[{inp.core_id}]: target `{target}` ok", - ) - - -# ── storage backends ──────────────────────────────────────────────────────── - -PIPE = "|" - - -def plan_yocto_wic(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: - """`yocto_wic_to_sd_or_emmc` / `yocto_wic`: bmaptool (preferred) or dd to a - raw `/dev/` block device. Compressed images pipe `gunzip`/`xz` into `dd`. - Planning-only unless the confirm gate is armed.""" - fa = inp.flash_args - target = fa_str(fa, "target") - if target is None: - raise FlashPlanError("yocto_wic: flash_args.target is required (e.g. /dev/sdb)") - if not target.startswith("/dev/"): - raise FlashPlanError( - f"yocto_wic: refusing target '{target}' -- must start with /dev/ to avoid " - "clobbering a regular file. Set flash_args.target to a real block device." - ) - artefact = inp.artefact - compress = fa_str(fa, "compress") - if compress is None: - suffix = os.path.splitext(artefact)[1].lstrip(".") - compress = suffix if suffix in ("gz", "xz") else None - confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False) - planning_only = inp.dry_run or not confirm - - bmaptool = which("bmaptool") - dd = which("dd") - if bmaptool or (planning_only and not dd): - argv: tuple[str, ...] = ("bmaptool", "copy", artefact, target) - elif dd: - bs = _default(fa_str(fa, "bs"), "4M") - dd_cmd = ["dd", f"of={target}", f"bs={bs}", "conv=fsync", "status=progress"] - if compress == "gz": - if which("gunzip"): - dcmp = ["gunzip", "-c", artefact] - elif which("gzip"): - dcmp = ["gzip", "-dc", artefact] - else: - raise FlashPlanError( - "yocto_wic: compressed .wic.gz fallback needs `gunzip` or `gzip` on PATH." - ) - argv = tuple([*dcmp, PIPE, *dd_cmd]) - elif compress == "xz": - if not which("xz"): - raise FlashPlanError( - "yocto_wic: compressed .wic.xz fallback needs `xz` on PATH." - ) - argv = tuple(["xz", "-dc", artefact, PIPE, *dd_cmd]) - else: - argv = ( - "dd", f"if={artefact}", f"of={target}", f"bs={bs}", - "conv=fsync", "status=progress", - ) - else: - raise FlashPlanError( - "yocto_wic: neither `bmaptool` nor `dd` is on PATH; install bmaptool " - "(preferred -- sparse aware) via `apt install bmap-tools` or run on a " - "host with coreutils." - ) - return FlashPlan( - argv=argv, - ok_message=f"yocto_wic[{inp.core_id}]: programmed {target}", - planning_only=planning_only, - ) - - -def plan_xspi_flashwriter(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: - """`xspi_flashwriter`: Renesas Flash Writer over SCIF. Planning-only unless - confirmed; the confirmed real write is HW-gated and fails today.""" - del which - fa = inp.flash_args - partition = _default(fa_str(fa, "flash_partition"), "") - if partition not in ("mtd0", "mtd1"): - raise FlashPlanError( - "xspi_flashwriter: flash_args.flash_partition must be 'mtd0' (bl2) or 'mtd1' (fip)" - ) - port = _default(fa_str(fa, "port"), "") - writer = _default(fa_str(fa, "flash_writer"), "") - baud = _default(fa_int_checked(fa, "baud"), 115200) - artefact_name = os.path.basename(inp.artefact) - argv = ( - "flash-writer-scif", f"port={port}", f"writer={writer}", f"baud={baud}", - f"partition={partition}", f"artefact={artefact_name}", - ) - confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False) - if inp.dry_run or not confirm: - why = "dry-run" if inp.dry_run else "flash_args.confirm is false" - return FlashPlan( - argv=argv, - ok_message=( - f"xspi_flashwriter[{inp.core_id}]: would write {artefact_name} -> xSPI " - f"{partition} via Flash Writer on {port} ({why})" - ), - planning_only=True, - ) - raise FlashPlanError( - "xspi_flashwriter: the real SCIF write is HW-gated and not yet validated on " - "silicon (bench shelved). Run with --dry-run; see docs/provisioning.md." - ) - - -# ── Flow D: J-Link direct MRAM write ──────────────────────────────────────── - -#: The `flash_args` key that ARMS Flow D. Only the part-number device profile -#: is required: without it J-Link has no MRAM loader at all, so its presence -#: alone is metadata's statement that this silicon has one. `slot0_load_address` is -#: NOT an arming key -- it does not exist in any alp-sdk branch today (see -#: `plan_alif_mram_jlink`'s shape note) and, even once published, it only ever -#: selects the two-blob mramxip SHAPE, an ITCM-overflow exception, not whether -#: Flow D applies at all. Requiring it here would leave Flow D permanently -#: unarmed for every real AEN entry, which is the bug this comment replaces. -FLOW_D_KEYS = ("jlink_flash_device",) -FLOW_D_METHOD = "alif_mram_jlink" - - -def flow_d_available(flash_args: Any) -> bool: - """Whether the manifest armed Flow D for this entry, i.e. supplied every - key in `FLOW_D_KEYS`. Purely a data question -- see `select_flash_method`. - - KEY PRESENCE, deliberately -- not "resolves to a non-null/non-empty - string": an `is not None` check collapses a present-but-null - `jlink_flash_device:` (bare YAML null) to "absent" and SILENTLY routes the - entry to Flow A over the SE-UART instead, with no diagnostic at all. - Transport must never be decided by a quoting detail. Using `_fa_has_key` - arms Flow D on presence alone, so a present-but-null/malformed value still - reaches `plan_alif_mram_jlink`, which turns it into the loud refusal it - already produces for every other malformed Flow D field -- not a silent - Flow A fallback. `fa_str_checked` itself only raises on a genuinely - malformed (wrong-type) value; for present-but-null it quietly returns - `None` same as for absent, so it is `plan_alif_mram_jlink`'s own explicit - `_fa_has_key` re-check on that `None` (distinguishing "present but - null/empty" from "absent") that decides the present-but-null case, not - `fa_str_checked`'s own check. - """ - return all(_fa_has_key(flash_args, key) for key in FLOW_D_KEYS) - - -def select_flash_method(target: FlashTarget) -> str | None: - """The `flash_method` actually dispatched for `target` -- **Flow D by - default, Flow A as the fallback.** - - Two host paths put a signed image into MRAM on an Alif Ensemble part. Both - need the SETOOLS `app-gen-toc` step to sign the ATOC; they differ only in - TRANSPORT, and the transport is the part tan owns: - - * **Flow A** -- `zephyr_west_flash` with no runner, so `west flash` picks the - board.cmake default (`alif_flash`) and burns over the SE-UART. Needs a - dedicated 1.8 V-capable USB-UART, which the bench runbook calls the #1 - trap. - * **Flow D** -- `alif_mram_jlink`: J-Link straight over SWD, no SE-UART. Same - blob(s), same addresses, ~0.16 s, and the bench's day-to-day default - (`docs/aen-bench-bringup.md`: "Flow D is the day-to-day default now"). - - The switch is made **entirely from data**, never from silicon knowledge: a - `zephyr_west_flash` entry whose `flash_args` carries `FLOW_D_KEYS` is - dispatched as Flow D instead. tan cannot ask "is this an AEN MRAM part?" -- - that would put a SKU or an address in tan, which ADR-0017 / I-26 forbid and - no gate would catch. What it CAN ask is "did the SoM preset hand me a - part-number J-Link profile for this slice?", because that arriving at all - IS metadata's statement that this silicon has a J-Link MRAM loader. - - Consequence, stated plainly: with today's emit - (`tan/planner/orchestrator.py::_slice_flash_recipe` returns - `("zephyr_west_flash", {})` for every Zephyr slice) NO entry carries that - key, so every AEN slice still takes Flow A. Arming Flow D is now a - one-function change in THIS repo; it is deliberately NOT emulated here by - sniffing the SKU. - """ - method = target.flash_method or None - if method == "zephyr_west_flash" and flow_d_available(target.flash_args): - return FLOW_D_METHOD - return method - - -def parse_atoc_start_address(text: str) -> str | None: - """The ATOC package's MRAM placement out of an `app-gen-toc` - `app-package-map.txt` report -- the LAST `APP Package Start Address:` - line's last field, mirroring every bench script's own - ``awk '/APP Package Start Address:/{print $NF}' app-package-map.txt | tail - -1`` byte for byte (last match wins: a re-signed re-run APPENDS a fresh - block rather than truncating the file, per - `scripts/bench/aen/flash-jlink.sh`/`flash-jlink-mramxip.sh`/ - `flash-update-log-dual.sh`). `None` when the marker never appears -- an - empty, foreign or not-yet-signed file, not a malformed one; the caller - decides what that means. - - **This is a BUILD-TIME output, never plan-time metadata.** `app-gen-toc` - writes the address fresh at signing time and the runbook says outright it - SHIFTS per build/config -- no field under `metadata/**` can express it, so - parsing this report is the only correct source. See `plan_alif_mram_jlink` - for the required/optional split this feeds; the actual file read happens - in `tan.commands.flash_cmd` (IO), never here. - """ - address: str | None = None - for line in text.splitlines(): - if "APP Package Start Address:" not in line: - continue - fields = line.split() - if fields: - address = fields[-1] - return address - - -def plan_alif_mram_jlink(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: - """Flow D: burn the signed ATOC into MRAM over SWD with J-Link's built-in - Alif MRAM loader, verify it, then PIN-reset so the Secure Enclave boot ROM - boots the image -- the same blob(s) at the same addresses SETOOLS writes - over the SE-UART, so no re-signing and no keys. - - **Two shapes, selected from data, matching the two bench scripts they - port** (`scripts/bench/aen/flash-jlink.sh` / `flash-jlink-mramxip.sh`): - - * **Default -- single ATOC blob.** The day-to-day flow - (`flash-jlink.sh`): the ATOC is self-contained (an ITCM-load package, - its own embedded load address set by `app-gen-toc`), so ONE - `loadbin`/`verifybin` of `atoc` at `atoc_address` is the whole write. - This is what runs whenever `flash_args` omits `slot0_load_address`. - * **mramxip -- two blobs.** The ITCM-overflow exception - (`flash-jlink-mramxip.sh`), for an app LINKED into MRAM slot0 (built - with `CONFIG_USE_DT_CODE_PARTITION=y`, a per-app-build opt-in tan does - not set): the app blob itself also needs writing, to `slot0_load_address`, - ahead of the ATOC. This activates only when `flash_args.slot0_load_address` - is present -- tan cannot detect the Kconfig opt-in from here, so a - manifest that arms the mramxip shape must supply the address that - proves it was built that way. - - **Every identifier is read from `flash_args`; none is baked in.** Required - in both shapes: - - * `jlink_flash_device` -- the PART-NUMBER device profile. Only this unlocks - the loader; with a generic `Cortex-M55` profile there is no loader and - `loadbin` to MRAM does nothing useful. It is also the wrong profile for - attaching to a live core, which is why it is a distinct metadata key - (`jlink_flash_device`, not `jlink_device`) on the SoC spec. - * `atoc` + `atoc_address` -- the signed ATOC blob and its MRAM placement. - The address SHIFTS per build/config and the runbook says outright not to - hardcode it -- it is a BUILD-TIME output of the signing step, never a - metadata fact, so this function still requires it as a plain - `flash_args` value and REFUSES when it is absent. tan does NOT run - `app-gen-toc` here either way: signing is common to both flows and - belongs to whatever produced the ATOC. What changed is only WHO fills - `atoc_address` in before this function runs -- `tan.commands.flash_cmd` - resolves it from `flash_args.atoc_map` (an `app-package-map.txt` path) - via `parse_atoc_start_address` when the manifest supplies that instead - of a baked-in address, so a customer's manifest never has to hardcode a - value that changes every build. `atoc` itself is read here VERBATIM -- - this module has no filesystem access to resolve it against -- so - `tan.commands.flash_cmd` also anchors it on `build_root`/`sdk_root` - (`resolve_artefact_path`, the same resolver `output_artefact`/ - `atoc_map` use) before this function ever sees it; a caller that skips - that step hands this function a path relative to WHATEVER the eventual - spawn's cwd turns out to be, not the build root. - - Optional, mramxip-only: - - * `slot0_load_address` -- where the slot0-linked app itself sits, so the SE boots - it in place rather than loading it out of the ATOC. Present but - malformed is still a loud refusal, never a silent fall-back to the - default shape -- a quoting detail must never decide which shape burns. - - Absent a required identifier this REFUSES. There is no default to fall - back to: a guessed MRAM address is a write to the wrong place on a part - whose Secure Enclave then boots whatever is there. - - Confirm-gated (`flash_args.confirm` OR `ALP_FLASH_FORCE=1`) like the other - two persistent-device backends -- see the `planning_only` note below. - """ - fa = inp.flash_args - device = fa_str_checked(fa, "jlink_flash_device", False) - if device is None: - if _fa_has_key(fa, "jlink_flash_device"): - raise FlashPlanError( - f"{FLOW_D_METHOD}: flash_args.jlink_flash_device is present but " - "null/empty -- refusing to write MRAM with no part-number J-Link " - "device profile; the generic profile has none. It is a per-variant " - "metadata fact (socs/**/*.json `variants[].debug.jlink_flash_device`); " - "tan does not guess a part number." - ) - raise FlashPlanError( - f"{FLOW_D_METHOD}: flash_args.jlink_flash_device is required -- only the " - "part-number J-Link device profile unlocks the MRAM loader, and the " - "generic profile has none. It is a per-variant metadata fact " - "(socs/**/*.json `variants[].debug.jlink_flash_device`); tan does not " - "guess a part number." - ) - validate_identifier(device, "jlink_flash_device") - # OPTIONAL -- selects the mramxip two-blob shape when present; the default - # single-ATOC-blob shape (flash-jlink.sh) needs no app-address write at - # all, since the ATOC embeds the app. `None` only for genuinely absent; a - # present-but-malformed value still raises below, never silently reverts - # to the default shape. - # - # `fa_str_checked` alone cannot tell "key absent" from "key present with a - # null/empty-string value" -- both collapse to `None` (`raw or None` at - # line ~571). A key that IS present must still refuse when it resolves to - # `None`: a `slot0_load_address: ""` or a bare `slot0_load_address:` (YAML - # null) must never silently pick the default shape, exactly like any other - # malformed value. - app_address = fa_str_checked(fa, "slot0_load_address", True) - if app_address is None and _fa_has_key(fa, "slot0_load_address"): - raise FlashPlanError( - f"{FLOW_D_METHOD}: flash_args.slot0_load_address is present but " - "null/empty -- refusing to silently select the default " - "single-ATOC-blob shape. Remove the key entirely to use the default " - "shape, or supply the app's real MRAM address to select the mramxip " - "two-blob shape." - ) - if app_address is not None: - validate_address(app_address, "slot0_load_address") - - atoc = fa_str(fa, "atoc") - atoc_address = fa_str_checked(fa, "atoc_address", True) - if atoc is None or atoc_address is None: - raise FlashPlanError( - f"{FLOW_D_METHOD}: flash_args.atoc (the signed ATOC blob) and " - "flash_args.atoc_address are both required. Both flows burn the SAME " - "signed ATOC -- sign it with the SETOOLS `app-gen-toc` step and pass the " - "blob plus the placement its own report prints; the addresses shift per " - "build and must not be hardcoded." - ) - validate_address(atoc_address, "atoc_address") - - speed = _default(fa_int_checked(fa, "jlink_speed"), _DEFAULT_JLINK_SPEED) - # Probe serial: the ONLY disambiguator when a bench carries more than one - # J-Link. No default -- a bench-wide serial can be shared by two probes that - # differ only by USB path, and a silent default can select the wrong board. - serial = fa_str(fa, "jlink_serial") - # The expected SW-DP IDR. When the manifest supplies one, the Commander - # script connects with the READ profile first and the caller ABORTS unless - # that ID appears -- writing MRAM on the wrong attached board is the one - # unrecoverable mistake this path can make. A hardware value, so it comes - # from data: tan neither knows nor invents an IDR. - expect_dpidr = fa_str_checked(fa, "expect_dpidr", False) - if expect_dpidr is not None: - validate_address(expect_dpidr, "expect_dpidr") - - jlink = _JLINK_BINARIES[0] if inp.dry_run else next( - (n for n in _JLINK_BINARIES if which(n)), None - ) - if jlink is None: - raise FlashPlanError( - f"{FLOW_D_METHOD}: needs SEGGER J-Link on PATH (JLinkExe/JLink), on a " - "V9.46+ DLL with the probe on matched firmware -- the built-in Alif MRAM " - "loader ships with the DLL and older ones cannot connect with the " - "part-number device profile." - ) - - # Two-blob mramxip shape only when `slot0_load_address` armed it; otherwise the - # default single-ATOC-blob shape (flash-jlink.sh) writes nothing for the - # app -- the ATOC already embeds it. See the docstring's "two shapes" note. - lines: list[str] = [] - if serial is not None: - lines.append(f"SelectEmuBySN {serial}") - lines += ["si SWD", f"speed {speed}", f"device {device}", "connect"] - if app_address is not None: - lines.append(f"loadbin {inp.artefact} {app_address}") - lines.append(f"loadbin {atoc} {atoc_address}") - if app_address is not None: - lines.append(f"verifybin {inp.artefact} {app_address}") - lines += [ - f"verifybin {atoc} {atoc_address}", - # PIN reset (RSetType 2), then run: the Secure Enclave boot ROM re-reads - # and boots the ATOC, exactly as it does after an SE-UART burn. A core - # reset would leave the SE out of the loop. - "RSetType 2", - "r", - "g", - "exit", - ] - argv = ( - jlink, "-device", device, "-if", "SWD", "-speed", str(speed), - "-ExitOnError", "1", "-NoGui", "1", "-CommanderScript", - ) - confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False) - ok_message = ( - f"{FLOW_D_METHOD}[{inp.core_id}]: app -> {app_address}, signed ATOC -> " - f"{atoc_address} via J-Link ({device}); verified and PIN-reset" - if app_address is not None - else ( - f"{FLOW_D_METHOD}[{inp.core_id}]: signed ATOC (app embedded) -> " - f"{atoc_address} via J-Link ({device}); verified and PIN-reset" - ) - ) - return FlashPlan( - argv=argv, - ok_message=ok_message, - # `planning_only` -- and therefore the `planned` status + the - # `flash.confirm-required` warning -- for an UNCONFIRMED run, matching - # `yocto_wic`/`xspi_flashwriter`. This is a NEW backend, so nothing in - # the oracle is being diverged from, and it is the only backend in the - # registry that persistently programs on-die MRAM: a `tan flash` in a - # fresh customer's checkout must not silently reprogram an attached - # module. `swd_probe` is ungated for a reason that does not apply here - # (it targets an external helper MCU's own flash). - planning_only=inp.dry_run or not confirm, - jlink_script="\n".join(lines) + "\n", - ) - - -def validate_flow_d_preflight_args(flash_args: Any) -> tuple[str | None, str | None]: - """The presence/pairing/shape checks for Flow D's DPIDR preflight, - returning `(expect_dpidr, jlink_device)` -- both `None` (opted out) or - both set (validated). Raises `FlashPlanError` for every half-armed or - malformed shape; never touches a J-Link binary or builds the Commander - script, so the CALLER decides when to run it. `flow_d_preflight_script` - (write-path) runs it then builds the script from the same values; - `tan.commands.flash_cmd` also runs it PLAN-TIME, before the confirm/ - dry-run gate, so a half-armed or malformed manifest surfaces as a - `flash.entry-failed` issue in the planned envelope too -- not only at - real-write time. - - `None`/`None` only for a genuinely ABSENT `expect_dpidr`/`jlink_device` -- - the documented, test-pinned way to opt out of the preflight entirely. A - key that IS present must still refuse when it resolves to `None` - (`fa_str_checked` alone cannot tell "absent" from "present but - null/empty"; see `_fa_has_key`'s docstring): silently treating it as - absent would drop the SW-DP IDR check -- the one guard standing between a - wrong-board attach and an MRAM write -- with no diagnostic at all. - """ - fa = flash_args - expected = fa_str_checked(fa, "expect_dpidr", False) - if expected is None and _fa_has_key(fa, "expect_dpidr"): - raise FlashPlanError( - f"{FLOW_D_METHOD}: flash_args.expect_dpidr is present but null/empty -- " - "refusing to silently skip the pre-write SW-DP IDR check. Remove the key " - "entirely to skip the preflight, or supply the board's real expected ID." - ) - read_device = fa_str_checked(fa, "jlink_device", False) - if read_device is None and _fa_has_key(fa, "jlink_device"): - raise FlashPlanError( - f"{FLOW_D_METHOD}: flash_args.jlink_device is present but null/empty -- " - "refusing to silently skip the pre-write SW-DP IDR check. Remove the key " - "entirely to skip the preflight, or supply the live-core read device " - "profile." - ) - # Half-armed by a genuinely ABSENT partner key (not a null one -- that is - # the two checks above): supplying `expect_dpidr` is the manifest's - # unambiguous statement that it wanted the wrong-board guard armed, and - # the reverse holds for `jlink_device`. Silently returning `None` here - # would drop the SW-DP IDR check with no diagnostic at all, immediately - # before the one write this backend's own docstring calls unrecoverable. - if (expected is None) != (read_device is None): - present_key, absent_key = ( - ("expect_dpidr", "jlink_device") - if expected is not None - else ("jlink_device", "expect_dpidr") - ) - raise FlashPlanError( - f"{FLOW_D_METHOD}: flash_args.{present_key} is present but flash_args." - f"{absent_key} is not -- refusing to silently skip the pre-write SW-DP " - "IDR check. Supply both flash_args.expect_dpidr and flash_args." - "jlink_device to arm the preflight, or remove both to skip it entirely." - ) - if expected is None or read_device is None: - return None, None - validate_address(expected, "expect_dpidr") - validate_identifier(read_device, "jlink_device") - return expected, read_device - - -def flow_d_preflight_script(inp: FlashInputs) -> tuple[str, str] | None: - """The read-only DPIDR preflight for a Flow D plan: `(script, expected_id)`, - or `None` when the manifest declared neither `expect_dpidr` nor - `jlink_device` at all -- see `validate_flow_d_preflight_args` for every - other case, which this delegates to before building the script. - - Run BEFORE any write, with the manifest's READ device profile (a live-core - attach profile, which the part-number one is not), so the caller can abort on - the wrong board while the session is still read-only. Both the device name - and the expected ID come from `flash_args`. - """ - expected, read_device = validate_flow_d_preflight_args(inp.flash_args) - if expected is None or read_device is None: - return None - fa = inp.flash_args - speed = _default(fa_int_checked(fa, "jlink_speed"), _DEFAULT_JLINK_SPEED) - lines = [] - serial = fa_str(fa, "jlink_serial") - if serial is not None: - lines.append(f"SelectEmuBySN {serial}") - lines += ["si SWD", f"speed {speed}", f"device {read_device}", "connect", "exit"] - return "\n".join(lines) + "\n", expected - - -_REGISTRY: dict[str, BackendMeta] = { - "swd_probe": BackendMeta(("JLinkExe", "JLink", "openocd", "pyocd"), plan_swd_probe), - "zephyr_west_flash": BackendMeta(("west",), plan_zephyr_west_flash), - "baremetal_cmake_flash": BackendMeta(("cmake",), plan_baremetal_cmake_flash), - "yocto_wic_to_sd_or_emmc": BackendMeta(("bmaptool", "dd"), plan_yocto_wic), - "yocto_wic": BackendMeta(("bmaptool", "dd"), plan_yocto_wic), - "xspi_flashwriter": BackendMeta((), plan_xspi_flashwriter), - FLOW_D_METHOD: BackendMeta(("JLinkExe", "JLink"), plan_alif_mram_jlink), -} - - -# ── the required-tool gate ────────────────────────────────────────────────── - -PROCEED = "proceed" -SKIP = "skip" -FAIL = "fail" - - -@dataclass(frozen=True) -class ToolGate: - outcome: str - message: str = "" - - -def tool_gate( - requires, - dry_run: bool, - skip_missing: bool, - kind: str, - entry_id: str, - method: str, - which: Callable[[str], bool], -) -> ToolGate: - """A backend is usable when AT LEAST ONE of `requires` is on PATH. Bypassed - entirely under `--dry-run`, and for a backend with an empty `requires`.""" - if dry_run or not requires: - return ToolGate(PROCEED) - if any(which(t) for t in requires): - return ToolGate(PROCEED) - msg = ( - f"flash: {kind} '{entry_id}' backend '{method}' needs one of " - f"{_str_list_debug(requires)} on PATH; none found." - ) - if skip_missing: - return ToolGate(SKIP, f"{msg} (skipped via --skip-missing-tools)") - return ToolGate(FAIL, msg) - - -# ── argv display ──────────────────────────────────────────────────────────── - - -def display_argv(plan: FlashPlan) -> str: - """The would-run display string; a J-Link plan shows a `` - placeholder for the temp Commander script (which does not exist yet, and - whose name carries a pid + nanosecond stamp that must never reach a - golden).""" - parts = list(plan.argv) - if plan.jlink_script is not None: - parts.append("") - return " ".join(parts) +# SPDX-License-Identifier: Apache-2.0 +"""Pure planning for ``tan flash`` -- the decision + argv-building half. + +Port of ``crates/tan-core/src/flash/`` (``mod.rs`` / ``args.rs`` / +``builders.rs`` / ``registry.rs`` / ``storage.rs``) plus the manifest reader in +``crates/tan-core/src/system_manifest.rs``. Every string, argv, filter and +per-backend command shape lives here with NO IO; the subprocess / filesystem / +temp-file half is ``tan.commands.flash_cmd``. + +The flow mirrors ``alp_flash.dispatch`` + ``_flash_entry``: walk the manifest's +``boot_order`` (or the sorted slice ``core_id``s when empty), map each step to +its slice, append the helper MCUs after, then dispatch each entry's +``flash_method`` to a backend plan-builder. + +**Strict ``flash_args`` reading.** A whole ``flash_args`` that is not a mapping +(the AEN701 helper's ``flash_args: TBD`` string) reads as an empty map -- but a +sub-key that IS present is read STRICTLY: every behaviour-affecting bool/int +(``erase``, ``use_openocd``, ``reset``, ``base``, ``baud``, ...) goes through a +``_checked`` accessor that hard-errors on a wrong-type scalar rather than +silently defaulting, since a wrong flash is worse than a refused one. Do not +reintroduce a tolerant bool/int reader here. + +**No hardware facts (I-26 / ADR-0017).** Nothing in this module names a SKU, an +address, a pin, an I2C address, a probe serial or a vendor branch. Every such +value arrives in ``flash_args``, passed through from alp-sdk ``metadata/``. The +ONE exception is inherited verbatim from the Rust oracle and flagged at its +definition (``_DEFAULT_JLINK_DEVICE``); do not add a second. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any, Callable + +from tan.core.pending import PENDING_PLACEHOLDER as PENDING_SENTINEL, is_pending_placeholder + +#: The system-manifest schema major this command consumes. A different value is +#: REFUSED rather than read as if it were v1 -- mirrors +#: `system_manifest.rs::SYSTEM_MANIFEST_SCHEMA_VERSION`. +SYSTEM_MANIFEST_SCHEMA_VERSION = 1 + +_DEFAULT_BASE = "0x08000000" +#: INHERITED HARDWARE FACT, not a new one. `builders.rs:15`'s +#: `DEFAULT_JLINK_DEVICE`. This is a part number in tan, which ADR-0017 / I-26 +#: forbids, and it is already shipped in the Rust binary -- changing or dropping +#: it here would make the port disagree with the oracle on every `swd_probe` +#: entry whose `flash_args` omits `jlink_device`. Kept byte-identical and +#: quarantined to this one constant; the correct fix is for the SoM preset to +#: always supply `flash_args.jlink_device` (E1M-V2N101 already does not), after +#: which this default becomes unreachable and can be deleted on BOTH sides. +_DEFAULT_JLINK_DEVICE = "GD32G553MEY7TR" +_DEFAULT_JLINK_SPEED = 4000 +_JLINK_BINARIES = ("JLinkExe", "JLink") + + +class ManifestError(Exception): + """`build/system-manifest.yaml` could not be consumed. `message` is the + human text; the caller pairs it with `flash.manifest-invalid`.""" + + +class FlashPlanError(Exception): + """A backend refused to build a plan -- the `Err(String)` arm of every + `plan_*` builder in `builders.rs`/`storage.rs`. The message is reported + verbatim as the entry's `message`.""" + + +# ── manifest reading ──────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class Slice: + """One per-core image from the manifest's `slices[]`. Tolerant reader: only + the fields `tan flash` consumes are modeled and unknown additive-v1 keys are + ignored, per the stability policy in `system_manifest.rs`.""" + + core_id: str + os: str + status: str = "" + output_artefact: str | None = None + flash_method: str | None = None + flash_args: Any = None + + +@dataclass(frozen=True) +class HelperMcu: + """One on-module helper MCU from `helper_mcus[]`.""" + + name: str + firmware_path: str | None = None + flash_method: str | None = None + flash_args: Any = None + update_channel: str | None = None + + +@dataclass(frozen=True) +class Manifest: + sku: str = "" + slices: tuple[Slice, ...] = () + helper_mcus: tuple[HelperMcu, ...] = () + boot_order: tuple[Any, ...] = () + + +def _opt_str(raw: Any) -> str | None: + """A manifest string field, or `None`. A non-string scalar reads as absent + rather than being coerced: `serde` would have failed the whole document, and + `str(4)` here would silently invent a path/method name.""" + return raw if isinstance(raw, str) else None + + +def parse_system_manifest(text: str) -> Manifest: + """Parse + version-guard a `system-manifest.yaml` document. + + Raises `ManifestError` for: PyYAML unavailable, malformed YAML, a non-mapping + document, a `schema_version` that is not 1, or a `slices[]`/`helper_mcus[]` + entry missing a field the Rust struct declares non-`Option` (`core_id`/`os` + for a slice, `name`/`chip` for a helper) -- serde fails the ENTIRE parse in + that last case, so a partial read here would flash against a manifest the + oracle rejects. + + tan ships no YAML dependency of its own (`python/pyproject.toml`), so PyYAML + is imported lazily. Its absence is FATAL here, unlike in `debug-config` + where the manifest is a best-effort enrichment: `flash` cannot pick a target + or an artefact without it, and silently flashing nothing would be the worse + outcome. + """ + try: + import yaml # noqa: PLC0415 (optional at runtime, by design) + except ImportError as err: + raise ManifestError( + "reading a system-manifest needs PyYAML, which is not importable " + f"({err}); install it (`pip install pyyaml`) or run tan from a " + "bootstrapped workspace" + ) from err + try: + doc = yaml.safe_load(text) + except Exception as err: # noqa: BLE001 -- the SDK's output, not ours + raise ManifestError(f"system-manifest is not valid YAML: {err}") from err + if doc is None or not isinstance(doc, dict): + raise ManifestError( + "system-manifest is not valid YAML: expected a mapping at the " + f"document root, got {type(doc).__name__}" + ) + version = doc.get("schema_version") + if version != SYSTEM_MANIFEST_SCHEMA_VERSION: + raise ManifestError( + f"unsupported system-manifest schema_version {version} (this CLI " + f"consumes v{SYSTEM_MANIFEST_SCHEMA_VERSION}); upgrade the CLI or " + "the SDK so the versions match" + ) + + hw_info = doc.get("hw_info") + sku = "" + if isinstance(hw_info, dict) and isinstance(hw_info.get("sku"), str): + sku = hw_info["sku"] + + slices: list[Slice] = [] + for raw in _seq(doc.get("slices")): + if not isinstance(raw, dict): + raise ManifestError("system-manifest is not valid YAML: slices[] entry is not a mapping") + core_id, os_name = raw.get("core_id"), raw.get("os") + if not isinstance(core_id, str) or not isinstance(os_name, str): + raise ManifestError( + "system-manifest is not valid YAML: every slices[] entry needs a " + "string `core_id` and `os`" + ) + slices.append( + Slice( + core_id=core_id, + os=os_name, + status=raw["status"] if isinstance(raw.get("status"), str) else "", + output_artefact=_opt_str(raw.get("output_artefact")), + flash_method=_opt_str(raw.get("flash_method")), + flash_args=raw.get("flash_args"), + ) + ) + + helpers: list[HelperMcu] = [] + for raw in _seq(doc.get("helper_mcus")): + if not isinstance(raw, dict): + raise ManifestError( + "system-manifest is not valid YAML: helper_mcus[] entry is not a mapping" + ) + name, chip = raw.get("name"), raw.get("chip") + if not isinstance(name, str) or not isinstance(chip, str): + raise ManifestError( + "system-manifest is not valid YAML: every helper_mcus[] entry needs " + "a string `name` and `chip`" + ) + helpers.append( + HelperMcu( + name=name, + firmware_path=_opt_str(raw.get("firmware_path")), + flash_method=_opt_str(raw.get("flash_method")), + flash_args=raw.get("flash_args"), + update_channel=_opt_str(raw.get("update_channel")), + ) + ) + + return Manifest( + sku=sku, + slices=tuple(slices), + helper_mcus=tuple(helpers), + boot_order=tuple(_seq(doc.get("boot_order"))), + ) + + +def _seq(raw: Any) -> list[Any]: + """A manifest list field. `#[serde(default)]` means a missing key is an + empty list; a key present with a NON-list value is a shape error serde would + reject, so it is not silently treated as empty here either -- `[]` is + returned only for genuinely absent/null.""" + if raw is None: + return [] + if not isinstance(raw, list): + raise ManifestError( + f"system-manifest is not valid YAML: expected a sequence, got {type(raw).__name__}" + ) + return raw + + +# ── target selection ──────────────────────────────────────────────────────── + +SLICE = "slice" +HELPER = "helper" + + +@dataclass(frozen=True) +class FlashTarget: + """One manifest entry selected for flashing, in dispatch order.""" + + kind: str + id: str + flash_method: str | None + flash_args: Any + output_artefact: str | None = None + firmware_path: str | None = None + update_channel: str | None = None + + +@dataclass(frozen=True) +class TargetPlan: + targets: tuple[FlashTarget, ...] + warnings: tuple[str, ...] + refused: tuple[str, ...] + #: The subset of "status not ok" refusals whose slice `status` is + #: `"skipped"` -- i.e. `tan build` itself declined to build this slice + #: under `executionPolicy.missingTool`/`.nullCommand` (a host with no + #: `bitbake`, say). That was a policy decision already made and reported + #: at build time; `flash` refusing to flash a never-built artefact is + #: still correct (there is nothing to flash), but it must not ALSO read + #: as a flash failure for a slice the customer's manifest already + #: explained away. `refused` (a `"failed"`/`"pending"`/other status) is + #: the opposite: `tan build` tried and the slice is broken or was never + #: reconciled, which must keep failing `tan flash`. Callers surface this + #: bucket as a WARNING and must not fold it into a failure count -- see + #: `refused` for the error-severity, exit-code-affecting bucket. + #: + #: **DIVERGES from the shipped Rust oracle.** `crates/tan-core/src/ + #: flash/mod.rs`'s `plan_flash_targets` has no `refused_skipped` bucket at + #: all -- a `status: skipped` slice/helper lands in the ONE `refused` list + #: alongside `failed`/`pending`/anything else non-`ok`, and the CLI seeds + #: `failed` from `refused.len()` before the dispatch loop even runs, so the + #: oracle FAILS the run on a `status: skipped` slice exactly like any other + #: bad status. This split (and the caller's warning-only, exit-0 treatment + #: when something else DID flash) is a deliberate product improvement on + #: top of the port, not a porting bug -- but the caller (`tan.commands. + #: flash_cmd.flash`) MUST still fail the run when every match was a + #: `refused_skipped` entry and nothing flashed (`flash.nothing-flashed`), + #: or this bucket reintroduces the exact silent-success class `refused` + #: exists to prevent, just inverted. `tests/parity/ + #: test_flash_oracle_parity.py` deliberately carries no `status: skipped` + #: case for this reason -- the two implementations disagree there by + #: design and an oracle diff would only fail. + refused_skipped: tuple[str, ...] = () + + + +def plan_flash_targets( + manifest: Manifest, core: str | None = None, helper: str | None = None +) -> TargetPlan: + """Build the ordered flash target list + any `boot_order` warnings/refusals. + + - Empty `boot_order`: one step per slice `core_id`, sorted ascending. + - Non-empty `boot_order`: walked in order; a step naming a `core_id` not in + `slices` is dropped and surfaced as a warning. + - A slice whose `status` is not `ok` is REFUSED, not flashed and not silently + dropped: `overlay_run_results` PRESERVES the plan-time `output_artefact` + when a later run has no artefact for that core, so a run-1 success followed + by a run-2 failure/skip leaves run-1's elf on disk under a manifest + reporting a broken slice. Flashing that stale elf and silently dropping the + slice are the same silent-failure class. A `status: skipped` refusal is + split into `refused_skipped` rather than `refused`: `tan build` already + decided (via `executionPolicy`) that this slice was not supposed to build + on this host -- e.g. no `bitbake` on an MCU-only checkout -- and that is + not a flash failure, it is `tan flash` agreeing with a decision already + made and reported. A genuinely broken slice (`status: failed`, or any + other non-`ok`/non-`skipped` value) stays in `refused`. + - Helpers always come AFTER all slices. + - `core` flashes only that slice and skips every helper; `helper` skips every + slice and flashes only that helper. + + Callers MUST surface both `refused` and `refused_skipped`: those entries + never enter `targets`, so a caller that only reports `targets`/`warnings` + would show a clean run while a stale/never-built artefact stayed unflashed. + Only `refused` (not `refused_skipped`) may fail the overall run -- see + `TargetPlan.refused_skipped`. + """ + targets: list[FlashTarget] = [] + warnings: list[str] = [] + refused: list[str] = [] + refused_skipped: list[str] = [] + + def find_slice(cid: str) -> Slice | None: + # Non-empty core_id only, matching the Python dict-comprehension guard + # `alp_flash` used and the `!s.core_id.is_empty()` filter in Rust. + for s in manifest.slices: + if s.core_id and s.core_id == cid: + return s + return None + + if not manifest.boot_order: + steps = sorted(s.core_id for s in manifest.slices if s.core_id) + else: + steps = [] + for step in manifest.boot_order: + if not isinstance(step, dict): + continue + named = step.get("core") + if isinstance(named, str) and named: + steps.append(named) + + # A slice present in `slices` but never named by a `boot_order` step used to + # be dropped with NO warning at all -- a heterogeneous system silently + # flashed a strict subset of its cores and reported success. Only warn on the + # unfiltered default run: `--core` deliberately narrows the slice set and + # `--helper` deliberately suppresses every slice. + if manifest.boot_order and helper is None and core is None: + for s in manifest.slices: + if s.core_id and s.core_id not in steps: + warnings.append(f"flash: slice '{s.core_id}' has no boot_order entry; not flashed") + + if helper is None: + for cid in steps: + if core is not None and cid != core: + continue + found = find_slice(cid) + if found is None: + warnings.append( + f"flash: boot_order references core '{cid}' not in slices; skipping" + ) + continue + if not slice_should_flash(found.status): + if found.status == "skipped": + # A policy decision `tan build` already made and reported + # (`executionPolicy.missingTool`/`.nullCommand`), not a + # broken build -- "stale, rebuild it" is wrong on both + # counts: nothing was ever built, so nothing is stale, and + # rebuilding ON THIS HOST hits the same policy skip again. + refused_skipped.append( + f"flash: slice '{found.core_id}' build status is 'skipped' -- " + "tan build already declined to build it under executionPolicy " + "(a missing tool or a null command on this host); there is " + "nothing to flash. Rebuilding on this same host will skip it " + "again -- it needs a host where that tool resolves." + ) + else: + refused.append( + f"flash: slice '{found.core_id}' build status is " + f"'{found.status}' (not 'ok'); refusing to flash its artefact " + "-- it may be stale from a previous successful build. " + "Rebuild it first." + ) + continue + targets.append( + FlashTarget( + kind=SLICE, + id=found.core_id, + flash_method=found.flash_method, + flash_args=found.flash_args, + output_artefact=found.output_artefact, + ) + ) + + if core is None: + for h in manifest.helper_mcus: + if not h.name: + continue + if helper is not None and h.name != helper: + continue + targets.append( + FlashTarget( + kind=HELPER, + id=h.name, + flash_method=h.flash_method, + flash_args=h.flash_args, + firmware_path=h.firmware_path, + update_channel=h.update_channel, + ) + ) + + + return TargetPlan( + tuple(targets), tuple(warnings), tuple(refused), tuple(refused_skipped) + ) + + +def slice_should_flash(status: str) -> bool: + """A slice is flashed iff it built successfully. `image_bundle.rs:: + slice_should_bundle` -- the same one-line predicate, shared on purpose so + `flash` and `image` can never disagree about which artefacts are real.""" + return status == "ok" + + +# ── path helpers ──────────────────────────────────────────────────────────── + + +def is_rust_absolute(path: str) -> bool: + """`Path::is_absolute()` semantics, NOT `os.path.isabs`. + + On Windows Rust requires BOTH a prefix (drive/UNC) and a root, so a + rooted-but-driveless `/dev/sdb` or `\\x` is RELATIVE and `base.join(p)` + discards part of `base`. `os.path.isabs("/dev/sdb")` answered True on + Windows until Python 3.13 and False from 3.13 on -- so reaching for it would + make artefact resolution differ from the oracle AND differ between two + supported interpreters on the same host. + """ + if os.name == "nt": + drive, rest = os.path.splitdrive(path) + return bool(drive) and rest[:1] in ("\\", "/") + return path.startswith("/") + + +def resolve_artefact_path( + artefact: str, + build_root: str, + sdk_root: str | None, + is_file: Callable[[str], bool], +) -> str: + """Resolve a manifest artefact string to a path. Absolute strings pass + through; a relative string tries `build_root/artefact`, then + `sdk_root/artefact`, then west's NESTED `build_root/build/artefact`, and + falls back to the `build_root` candidate. `is_file` is injected to keep this + pure. + + The first two candidates and the fallback are `flash/mod.rs:: + resolve_artefact_path` verbatim. The third is the consumer half of **I-18**: + the planner emits `west build` with NO `-d`, so west's tree lands at + `/build/` while the plan's `artifacts` block still reports + `/zephyr/zephyr.elf`. Rust reconciles that at manifest-WRITE time + (`build/execute/manifest.rs::resolve_zephyr_artefact`, tan's only writer of + `output_artefact`, which stores the nested ABSOLUTE path); this port's + `build` does not write the manifest yet, so an artefact string that still + carries the planner's un-nested spelling would resolve to a file that is not + there and fail the entry. Probed LAST and only when the oracle's own + candidates all miss a real file, so it can never change a resolution the + oracle already makes -- an absolute artefact never reaches it at all. + """ + if is_rust_absolute(artefact): + return artefact + cand_build = os.path.join(build_root, artefact) + if sdk_root is None: + return cand_build + if is_file(cand_build): + return cand_build + cand_sdk = os.path.join(sdk_root, artefact) + if is_file(cand_sdk): + return cand_sdk + cand_nested = os.path.join(build_root, "build", artefact) + if is_file(cand_nested): + return cand_nested + return cand_build + + +# ── flash_args accessors ──────────────────────────────────────────────────── + + +def _fa_get(value: Any, key: str) -> Any: + """A `flash_args` sub-key, or `None` when `flash_args` is not a mapping. + Mirrors `args.rs::fa_get`'s `v.as_mapping()?`: the AEN701 helper's + `flash_args: TBD` string reads as an empty map, not an error.""" + if not isinstance(value, dict): + return None + return value.get(key) + + +def _fa_has_key(value: Any, key: str) -> bool: + """Whether `flash_args` is a mapping that carries `key` AT ALL -- + independent of what it resolves to. `_fa_get`/`fa_str_checked` collapse a + present-but-null value and a genuinely-absent key to the same `None`, + which is right for every OPTIONAL field but wrong for one that must + distinguish "not selected" from "selected with a malformed value" (see + `slot0_load_address` in `plan_alif_mram_jlink`, and `expect_dpidr` / + `jlink_device` in `flow_d_preflight_script`).""" + return isinstance(value, dict) and key in value + + +def _yaml_debug(value: Any) -> str: + """`serde_yaml::Value`'s `{:?}` rendering, so the strict accessors' refusal + messages match the oracle byte for byte (`String("true")`, `Number(1)`, + `Bool(true)`, `Sequence [Number(1), Number(2)]`). Verified against the + shipped binary; the messages ship to the customer and to the extension's + issue list, and a diff harness that has to special-case them stops being + able to prove anything about the rest of the envelope.""" + if value is None: + return "Null" + if isinstance(value, bool): + return f"Bool({'true' if value else 'false'})" + if isinstance(value, str): + return f'String("{value}")' + if isinstance(value, (int, float)): + return f"Number({value})" + if isinstance(value, list): + return "Sequence [" + ", ".join(_yaml_debug(v) for v in value) + "]" + if isinstance(value, dict): + body = ", ".join(f"{_yaml_debug(k)}: {_yaml_debug(v)}" for k, v in value.items()) + return "Mapping {" + body + "}" + return f"String(\"{value}\")" + + +def fa_str(value: Any, key: str) -> str | None: + """A non-empty string sub-key; `None` when absent, empty, or non-string.""" + raw = _fa_get(value, key) + if isinstance(raw, str) and raw: + return raw + return None + + +def fa_bool_checked(value: Any, key: str) -> bool | None: + """Strict bool accessor for every behaviour-affecting `flash_args` bool + (`reset`, `erase`, `use_openocd`, `use_pyocd`, `confirm`, ...). + + A quoted `"false"` is NOT a bool, and a tolerant reader would read it as + absent, apply the caller's default and program the OPPOSITE of what was + written. `None` only for genuinely absent/null; any other shape raises.""" + raw = _fa_get(value, key) + if raw is None: + return None + if isinstance(raw, bool): + return raw + raise FlashPlanError( + f"flash_args.{key} must be a bare boolean (true/false, unquoted; got " + f"{_yaml_debug(raw)}) -- refusing to silently fall back to a default -- " + "this plans a real flash write." + ) + + +def fa_int_checked(value: Any, key: str) -> int | None: + """Strict int accessor (`jlink_speed`, `baud`, `jobs`, `speed`). + + `0`-means-absent semantics are preserved from the oracle: an explicit `0` + yields `None`, i.e. "use the default". `bool` is checked BEFORE `int` -- + Python's `True` IS an `int`, so an unguarded `isinstance(raw, int)` would + accept `jobs: true` and emit `-j 1`.""" + raw = _fa_get(value, key) + if raw is None: + return None + if isinstance(raw, bool): + raise FlashPlanError(_int_refusal(key, raw)) + if isinstance(raw, int): + return raw if raw != 0 else None + raise FlashPlanError(_int_refusal(key, raw)) + + +def _int_refusal(key: str, raw: Any) -> str: + return ( + f"flash_args.{key} must be a bare number (unquoted; got {_yaml_debug(raw)}) " + "-- refusing to silently fall back to a default -- this plans a real " + "flash write." + ) + + +def fa_str_checked(value: Any, key: str, as_hex_address: bool) -> str | None: + """Strict string accessor for fields where falling back to a baked-in default + is dangerous -- a flash base address, an OpenOCD interface/target name that + gets interpolated into a spawned command. + + `fa_str` treats ANY non-string value -- including the bare YAML integer an + unquoted `base: 0x08000000` resolves to -- as "absent", so the caller + silently substitutes the default and programs real silicon at the wrong + address with no warning. This returns `None` only for genuinely + absent/null/empty, round-trips a bare non-negative number back into a string + (hex for an address field, decimal otherwise), and refuses every other shape. + + A NEGATIVE number is refused outright rather than formatted: Rust's + `n as u64` sign-extends `-8` into `0xFFFFFFFFFFFFFFF8`, which + `validate_address` (a pure charset check) then ACCEPTS as a plausible + address and the J-Link/OpenOCD command interpolates verbatim. + """ + raw = _fa_get(value, key) + if raw is None: + return None + if isinstance(raw, bool): + # Guarded before the int arm for the same reason as `fa_int_checked`: + # `True` is an `int`, and `base: true` must not resolve to `0x00000001`. + raise FlashPlanError(_str_refusal(key, raw)) + if isinstance(raw, str): + return raw or None + if isinstance(raw, int): + if raw < 0: + raise FlashPlanError( + f"flash_args.{key} = {raw} is negative; refusing to interpret it as " + "an address/count -- this plans a real flash write." + ) + return f"0x{raw:08X}" if as_hex_address else str(raw) + raise FlashPlanError(_str_refusal(key, raw)) + + +def _str_refusal(key: str, raw: Any) -> str: + return ( + f"flash_args.{key} must be a quoted string (got {_yaml_debug(raw)}); " + "refusing to silently fall back to a default -- this plans a real flash write." + ) + + +def is_pending(value: Any) -> bool: + """Whether a manifest SCALAR is the SDK's unfilled-field sentinel. + + **The one definition for the whole flash path (#222).** Every guard in this + area used to test for EMPTY, and empty is the one thing a `TBD` placeholder + is not -- so an unfilled field behaved exactly like a filled one, and + whether that ended in a loud refusal or a spawned flasher came down to + whether the particular consumer happened to validate against a closed set. + `flash_method: TBD` hit the backend registry and failed safely; + `output_artefact`/`firmware_path: TBD` hit nothing at all, resolved to + `/TBD` and reached a real J-Link write. Route every new + manifest-derived field through THIS, never through a fresh `== "TBD"`. + + Trimmed before comparing -- a YAML `device: " TBD "` is the same unfilled + field -- but deliberately NOT case-folded and NOT a substring test: + `TBD-1234-XYZ` is a plausible part number and `flash_args.build_dir: + /opt/TBDtool/x` a plausible path, and refusing either would block a + legitimate flash. `tbd` lowercase is not the sentinel alp-sdk emits; + widening to it means widening the SDK's convention first, in one place, + not here. + + The comparison is the single `tan.core.pending.is_pending_placeholder` + definition (#276): the neutral module with no flash- or image-bundle + machinery behind it, so `tan.core.size` (and `pinmux`, once ported) can + read the same rule without pulling flash internals in. `PENDING_SENTINEL` + stays the name this module exports -- `flash_cmd` and the flash tests + already spell it that way -- but it is now an alias for + `pending.PENDING_PLACEHOLDER`, not a second definition. `tan image`'s own + `image_bundle.PENDING_SENTINEL` is still a separate `"TBD"` literal; + pointing it at the same module too is a follow-up outside flash_plan.py. + """ + return is_pending_placeholder(value) + + +def flash_args_has_tbd(value: Any) -> bool: + """Whether `flash_args` carries an unresolved `TBD` ANYWHERE -- a bare `TBD` + scalar, or a mapping/sequence value that trims to `TBD`. + + Deliberately broader than a single-key check: a `TBD` anywhere means the + entry is not finalised yet under the SDK's pending-placeholder convention. + Do not narrow this back to a set of known keys. Recurses into mapping VALUES + and sequence elements, not mapping keys: every accessor here reads by a + known key name, so a key literally named `TBD` selects nothing and cannot + reach an argv. + + This covers `flash_args` ONLY. The sibling artefact fields + (`output_artefact`/`firmware_path`) are NOT part of `flash_args` and are + guarded separately at the point of use -- see `is_pending`. + """ + if isinstance(value, str): + return is_pending(value) + if isinstance(value, dict): + return any(flash_args_has_tbd(v) for v in value.values()) + if isinstance(value, list): + return any(flash_args_has_tbd(v) for v in value) + return False + + +# ── validators ────────────────────────────────────────────────────────────── + + +def validate_identifier(text: str, field_name: str) -> None: + """Reject anything that is not a plain identifier, or a `/`-separated path + of plain identifier segments. + + `interface`/`target` are interpolated verbatim into an OpenOCD + `-f .cfg` path and a `-c` Tcl command string, so an unrestricted value + is a path-traversal + Tcl-injection primitive into a process routinely run + with device-flashing privileges. Multi-segment is allowed because OpenOCD + ships interface configs in subdirectories (`ftdi/olimex-arm-usb-ocd-h`). + + Rust composes `path_guard::is_plain_relative` with a per-segment charset + check. The charset alone is EQUIVALENT here and is what is implemented: the + only shapes `is_plain_relative` adds are absolute/rooted/drive-prefixed and + `.`/`..`, and every one of those carries a character (`/` leading -> an empty + segment, `:`, `\\`, `.`) the charset already rejects. Cross-checked against + the oracle on `a;b`, `../x`, `/x`, `\\x`, `C:/x`, `a//b`, `.`. + """ + segments = text.split("/") + ok = bool(text) and all( + seg and all(c.isascii() and (c.isalnum() or c in "-_") for c in seg) for seg in segments + ) + if not ok: + raise FlashPlanError( + f"flash_args.{field_name} = {_quoted(text)} is not a plain identifier or " + "'/'-separated path of plain identifiers (letters, digits, '-', '_' per " + "segment) -- refusing to interpolate it into a spawned command / OpenOCD " + "Tcl script." + ) + + +def validate_address(text: str, field_name: str) -> None: + """A flash base address must be purely hex digits, with an optional `0x`/`0X`. + + `base` is interpolated verbatim into a J-Link Commander script LINE and an + OpenOCD `-c` Tcl command string -- both line/command-oriented interpreters, + so a newline (or `;`, `[`, `]`) inside `base` runs arbitrary extra commands + against whatever silicon is attached. + """ + digits = text + for prefix in ("0x", "0X"): + if digits.startswith(prefix): + digits = digits[len(prefix) :] + break + if not digits or not all(c in "0123456789abcdefABCDEF" for c in digits): + raise FlashPlanError( + f"flash_args.{field_name} = {_quoted(text)} is not a plain hex/decimal " + "address -- refusing to interpolate it into a J-Link/OpenOCD command." + ) + + +#: `char::escape_debug`'s named escapes, which is what Rust's `{:?}` for a +#: `&str` emits. Applied in ONE pass -- escaping `\\` up front and then +#: re-scanning would revisit the backslashes it just added. +_DEBUG_ESCAPES = { + "\\": "\\\\", + '"': '\\"', + "\t": "\\t", + "\r": "\\r", + "\n": "\\n", +} + + +def _quoted(text: str) -> str: + """Rust's `{s:?}` for a `&str`. + + Not just `"` and `\\`: Rust escapes control characters too, so a `base` + containing a real newline renders as `"0x8000\\n r"` -- ONE line -- and not + as a refusal message split across two. These messages are exactly the ones + reporting an injection attempt (`validate_address`/`validate_identifier` + exist to catch a newline smuggled into a J-Link Commander script line), so a + diagnostic that itself breaks across lines is the worst possible rendering: + a reader sees a truncated message and the offending bytes on their own line. + Caught by the oracle diff, not by review. + """ + rendered = [ + _DEBUG_ESCAPES.get(char) + or (char if char.isprintable() else f"\\u{{{ord(char):x}}}") + for char in text + ] + return '"' + "".join(rendered) + '"' + + +def is_raw_bin(artefact: str) -> bool: + """Whether an artefact is a raw binary (needs an explicit load address), as + opposed to ELF/HEX which carry their own. Passing a load offset for a + non-`.bin` artefact shifts every section by that offset and writes outside + the intended flash region.""" + return os.path.splitext(artefact)[1].lower() == ".bin" + + +# ── the plan + backend registry ───────────────────────────────────────────── + + +@dataclass(frozen=True) +class FlashPlan: + """A built flash plan: the argv, the success message, whether it is + planning-only (never spawns real device IO), and -- for the J-Link path -- + the Commander script the caller must materialise to a temp file.""" + + argv: tuple[str, ...] + ok_message: str + planning_only: bool = False + jlink_script: str | None = None + + +@dataclass(frozen=True) +class BackendMeta: + """A registered backend: the tool-gate `requires` list + its plan-builder.""" + + requires: tuple[str, ...] + build: Callable[["FlashInputs", Callable[[str], bool]], FlashPlan] + + +@dataclass(frozen=True) +class FlashInputs: + """Everything a backend plan-builder consumes. Injected by the CLI layer.""" + + artefact: str + flash_args: Any + core_id: str + sku: str + dry_run: bool = False + #: The env half of the confirm gate (`ALP_FLASH_FORCE=1`). The per-entry + #: `flash_args.confirm` is OR-ed in by the gated builders, so the effective + #: gate is `flash_args.confirm OR ALP_FLASH_FORCE=1`. + force_confirm: bool = False + + +def backend_for(method: str) -> BackendMeta | None: + """Resolve a `flash_method` string to its backend metadata, or `None`.""" + return _REGISTRY.get(method) + + +def registry_keys() -> list[str]: + """The registered method names, sorted -- for the "Available: ..." error.""" + return sorted(_REGISTRY) + + +def registry_keys_debug() -> str: + """`{:?}` of a `Vec<&str>`, for the unknown-method message.""" + return _str_list_debug(registry_keys()) + + +def _str_list_debug(items) -> str: + return "[" + ", ".join(_quoted(i) for i in items) + "]" + + +# ── swd_probe ─────────────────────────────────────────────────────────────── + + +def commander_path(path: str) -> str: + """A path as it should be interpolated into a J-Link Commander script + line -- quoted when it CONTAINS whitespace, unchanged otherwise + (tan-cli#369). SEGGER's Commander splits an unquoted line on whitespace, + so an unquoted `loadbin C:\\Program Files\\alif\\setools\\build\\ + AppTocPackage.bin,
` silently truncates to `C:\\Program` -- + `-ExitOnError 1` turns that into a loud SEGGER parse error rather than a + mis-write, which is the only reason it was not a blocker. tan generates + every Commander script now, so tan owns making its own filenames parse + back correctly. Quoting is CONDITIONAL, not unconditional, so the + overwhelmingly common no-space path -- every already-measured + oracle/bench script -- renders byte-identical to before this fix. + """ + return f'"{path}"' if any(c.isspace() for c in path) else path + + +def jlink_commander_script(artefact: str, base: str, do_reset: bool) -> str: + """The J-Link Commander script: reset/halt, load (`loadbin`+base for `.bin`, + else `loadfile`), optional reset-and-go, quit-close.""" + lines = ["r", "halt"] + # `is_raw_bin` reads the extension via `os.path.splitext` -- checked on + # the UNQUOTED artefact, before `commander_path` may wrap it in `"..."`, + # which would otherwise shift the extension off the string entirely. + is_bin = is_raw_bin(artefact) + artefact = commander_path(artefact) + if is_bin: + lines.append(f"loadbin {artefact}, {base}") + else: + lines.append(f"loadfile {artefact}") + if do_reset: + lines += ["r", "g"] + lines.append("qc") + return "\n".join(lines) + "\n" + + +def plan_swd_probe(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: + """`swd_probe`: J-Link (primary) / OpenOCD / pyOCD.""" + fa = inp.flash_args + base = fa_str_checked(fa, "base", True) + if base is not None: + validate_address(base, "base") + else: + base = _DEFAULT_BASE + do_reset = _default(fa_bool_checked(fa, "reset"), True) + force_pyocd = _default(fa_bool_checked(fa, "use_pyocd"), False) + force_openocd = _default(fa_bool_checked(fa, "use_openocd"), False) + core = inp.core_id + is_bin = is_raw_bin(inp.artefact) + + # `--dry-run` is documented to bypass the required-tool PATH gate entirely; + # without the `inp.dry_run` bypass here this inner probe hard-failed a dry + # run on any box without a probe tool installed, making `--dry-run` + # host-dependent instead of a pure preview. + jlink: str | None = None + if not (force_pyocd or force_openocd): + if inp.dry_run: + jlink = _JLINK_BINARIES[0] + else: + jlink = next((n for n in _JLINK_BINARIES if which(n)), None) + if jlink is not None: + device = _default(fa_str_checked(fa, "jlink_device", False), _DEFAULT_JLINK_DEVICE) + speed = _default(fa_int_checked(fa, "jlink_speed"), _DEFAULT_JLINK_SPEED) + return FlashPlan( + argv=( + jlink, "-device", device, "-if", "SWD", "-speed", str(speed), + "-AutoConnect", "1", "-ExitOnError", "1", "-NoGui", "1", + "-CommanderScript", + ), + ok_message=( + f"swd_probe[{core}]: GD32G553 flashed via J-Link ({device}) @ {base}" + ), + jlink_script=jlink_commander_script(inp.artefact, base, do_reset), + ) + + interface = _default(fa_str_checked(fa, "interface", False), "") + target = _default(fa_str_checked(fa, "target", False), "") + if not interface or not target: + raise FlashPlanError( + "swd_probe: flash_args.interface and flash_args.target are required for " + "the openocd/pyocd path (e.g. interface=cmsis-dap, target=gd32g553) -- " + "or install SEGGER J-Link for the primary path." + ) + validate_identifier(interface, "interface") + validate_identifier(target, "target") + openocd = not force_pyocd and (inp.dry_run or which("openocd")) + pyocd = not force_openocd and (inp.dry_run or which("pyocd")) + if openocd: + program = f"program {inp.artefact} verify" + if do_reset: + program += " reset" + # `base` is a load OFFSET, meaningful only for a raw `.bin`; ELF/HEX + # carry their own addresses and OpenOCD's `program` proc adds a trailing + # address to them, so passing it unconditionally shifts every section. + program += f" exit {base}" if is_bin else " exit" + argv = ( + "openocd", "-f", f"interface/{interface}.cfg", + "-f", f"target/{target}.cfg", "-c", program, + ) + elif pyocd: + parts = ["pyocd", "flash", "--target", target] + # pyOCD's --base-address is documented binary-only; passing it for an + # ELF/HEX is meaningless at best and a wrong-address write at worst. + if is_bin: + parts += ["--base-address", base] + parts.append(inp.artefact) + argv = tuple(parts) + else: + raise FlashPlanError( + "swd_probe: no flash tool found -- install SEGGER J-Link (preferred), " + "or `openocd`, or `pyocd`." + ) + return FlashPlan(argv=argv, ok_message=f"swd_probe[{core}]: GD32G553 flashed @ {base}") + + +def _default(value, fallback): + """`Option::unwrap_or`. Spelled out because `value or fallback` is WRONG for + every falsy-but-present value this module reads -- `reset: false`, + `jlink_speed` legitimately absent-as-0, `interface: ""`.""" + return fallback if value is None else value + + +# ── zephyr_west_flash / baremetal_cmake_flash ─────────────────────────────── + + +def zephyr_build_dir(artefact: str) -> str: + """The Zephyr build dir derived from the artefact: `parent.parent` when the + artefact sits directly in a `zephyr/` subdirectory, else `parent`. + + Checks the PARENT DIRECTORY NAME, never the artefact's basename: an + MCUboot-signed (`zephyr.signed.hex`) or sysbuild (`merged.hex`) output still + lands in `/zephyr/` under a different name, and a basename + allowlist sent those one directory too deep -- `west flash --build-dir + ` then failed with no CMakeCache.txt there. + + `os.path.dirname`, not `Path.parent`: it slices the string and preserves + whatever separators the joined path already mixes (a native `build_root` + + a `/`-authored manifest artefact), exactly as Rust's `Path::parent` does. + """ + parent = os.path.dirname(artefact) + if os.path.basename(parent).lower() == "zephyr": + return os.path.dirname(parent) + return parent + + +def plan_zephyr_west_flash(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: + """`zephyr_west_flash`: `west flash --build-dir [--runner ] [--erase] + [--hex-file ]`. + + `runner` is OPTIONAL -- when absent, `--runner` is omitted and `west flash` + falls back to the board.cmake default runner (on an AEN board that is + `alif_flash`, i.e. Flow A over the SE-UART). + """ + del which # this backend probes nothing + fa = inp.flash_args + runner = fa_str(fa, "runner") + build_dir = _default(fa_str(fa, "build_dir"), zephyr_build_dir(inp.artefact)) + argv = ["west", "flash", "--build-dir", build_dir] + if runner is not None: + argv += ["--runner", runner] + if _default(fa_bool_checked(fa, "erase"), False): + argv.append("--erase") + hex_file = fa_str(fa, "hex_file") + if hex_file is not None: + argv += ["--hex-file", hex_file] + return FlashPlan( + argv=tuple(argv), + ok_message=( + f"zephyr_west_flash[{inp.core_id}]: programmed via " + f"{runner if runner is not None else 'board-default runner'}" + ), + ) + + +def plan_baremetal_cmake_flash(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: + """`baremetal_cmake_flash`: `cmake --build --target [--config ] [-j N]`.""" + del which + fa = inp.flash_args + build_dir = _default(fa_str(fa, "build_dir"), os.path.dirname(inp.artefact)) + target = _default(fa_str(fa, "target"), "flash") + argv = ["cmake", "--build", build_dir, "--target", target] + config = fa_str(fa, "config") + if config is not None: + argv += ["--config", config] + jobs = fa_int_checked(fa, "jobs") + if jobs is not None: + argv += ["-j", str(jobs)] + return FlashPlan( + argv=tuple(argv), + ok_message=f"baremetal_cmake_flash[{inp.core_id}]: target `{target}` ok", + ) + + +# ── storage backends ──────────────────────────────────────────────────────── + +PIPE = "|" + + +def plan_yocto_wic(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: + """`yocto_wic_to_sd_or_emmc` / `yocto_wic`: bmaptool (preferred) or dd to a + raw `/dev/` block device. Compressed images pipe `gunzip`/`xz` into `dd`. + Planning-only unless the confirm gate is armed.""" + fa = inp.flash_args + target = fa_str(fa, "target") + if target is None: + raise FlashPlanError("yocto_wic: flash_args.target is required (e.g. /dev/sdb)") + if not target.startswith("/dev/"): + raise FlashPlanError( + f"yocto_wic: refusing target '{target}' -- must start with /dev/ to avoid " + "clobbering a regular file. Set flash_args.target to a real block device." + ) + artefact = inp.artefact + compress = fa_str(fa, "compress") + if compress is None: + suffix = os.path.splitext(artefact)[1].lstrip(".") + compress = suffix if suffix in ("gz", "xz") else None + confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False) + planning_only = inp.dry_run or not confirm + + bmaptool = which("bmaptool") + dd = which("dd") + if bmaptool or (planning_only and not dd): + argv: tuple[str, ...] = ("bmaptool", "copy", artefact, target) + elif dd: + bs = _default(fa_str(fa, "bs"), "4M") + dd_cmd = ["dd", f"of={target}", f"bs={bs}", "conv=fsync", "status=progress"] + if compress == "gz": + if which("gunzip"): + dcmp = ["gunzip", "-c", artefact] + elif which("gzip"): + dcmp = ["gzip", "-dc", artefact] + else: + raise FlashPlanError( + "yocto_wic: compressed .wic.gz fallback needs `gunzip` or `gzip` on PATH." + ) + argv = tuple([*dcmp, PIPE, *dd_cmd]) + elif compress == "xz": + if not which("xz"): + raise FlashPlanError( + "yocto_wic: compressed .wic.xz fallback needs `xz` on PATH." + ) + argv = tuple(["xz", "-dc", artefact, PIPE, *dd_cmd]) + else: + argv = ( + "dd", f"if={artefact}", f"of={target}", f"bs={bs}", + "conv=fsync", "status=progress", + ) + else: + raise FlashPlanError( + "yocto_wic: neither `bmaptool` nor `dd` is on PATH; install bmaptool " + "(preferred -- sparse aware) via `apt install bmap-tools` or run on a " + "host with coreutils." + ) + return FlashPlan( + argv=argv, + ok_message=f"yocto_wic[{inp.core_id}]: programmed {target}", + planning_only=planning_only, + ) + + +def plan_xspi_flashwriter(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: + """`xspi_flashwriter`: Renesas Flash Writer over SCIF. Planning-only unless + confirmed; the confirmed real write is HW-gated and fails today.""" + del which + fa = inp.flash_args + partition = _default(fa_str(fa, "flash_partition"), "") + if partition not in ("mtd0", "mtd1"): + raise FlashPlanError( + "xspi_flashwriter: flash_args.flash_partition must be 'mtd0' (bl2) or 'mtd1' (fip)" + ) + port = _default(fa_str(fa, "port"), "") + writer = _default(fa_str(fa, "flash_writer"), "") + baud = _default(fa_int_checked(fa, "baud"), 115200) + artefact_name = os.path.basename(inp.artefact) + argv = ( + "flash-writer-scif", f"port={port}", f"writer={writer}", f"baud={baud}", + f"partition={partition}", f"artefact={artefact_name}", + ) + confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False) + if inp.dry_run or not confirm: + why = "dry-run" if inp.dry_run else "flash_args.confirm is false" + return FlashPlan( + argv=argv, + ok_message=( + f"xspi_flashwriter[{inp.core_id}]: would write {artefact_name} -> xSPI " + f"{partition} via Flash Writer on {port} ({why})" + ), + planning_only=True, + ) + raise FlashPlanError( + "xspi_flashwriter: the real SCIF write is HW-gated and not yet validated on " + "silicon (bench shelved). Run with --dry-run; see docs/provisioning.md." + ) + + +# ── Flow D: J-Link direct MRAM write ──────────────────────────────────────── + +#: The `flash_args` key that ARMS Flow D. Only the part-number device profile +#: is required: without it J-Link has no MRAM loader at all, so its presence +#: alone is metadata's statement that this silicon has one. `slot0_load_address` is +#: NOT an arming key -- it does not exist in any alp-sdk branch today (see +#: `plan_alif_mram_jlink`'s shape note) and, even once published, it only ever +#: selects the two-blob mramxip SHAPE, an ITCM-overflow exception, not whether +#: Flow D applies at all. Requiring it here would leave Flow D permanently +#: unarmed for every real AEN entry, which is the bug this comment replaces. +FLOW_D_KEYS = ("jlink_flash_device",) +FLOW_D_METHOD = "alif_mram_jlink" + + +def flow_d_available(flash_args: Any) -> bool: + """Whether the manifest armed Flow D for this entry, i.e. supplied every + key in `FLOW_D_KEYS`. Purely a data question -- see `select_flash_method`. + + KEY PRESENCE, deliberately -- not "resolves to a non-null/non-empty + string": an `is not None` check collapses a present-but-null + `jlink_flash_device:` (bare YAML null) to "absent" and SILENTLY routes the + entry to Flow A over the SE-UART instead, with no diagnostic at all. + Transport must never be decided by a quoting detail. Using `_fa_has_key` + arms Flow D on presence alone, so a present-but-null/malformed value still + reaches `plan_alif_mram_jlink`, which turns it into the loud refusal it + already produces for every other malformed Flow D field -- not a silent + Flow A fallback. `fa_str_checked` itself only raises on a genuinely + malformed (wrong-type) value; for present-but-null it quietly returns + `None` same as for absent, so it is `plan_alif_mram_jlink`'s own explicit + `_fa_has_key` re-check on that `None` (distinguishing "present but + null/empty" from "absent") that decides the present-but-null case, not + `fa_str_checked`'s own check. + """ + return all(_fa_has_key(flash_args, key) for key in FLOW_D_KEYS) + + +def select_flash_method(target: FlashTarget) -> str | None: + """The `flash_method` actually dispatched for `target` -- **Flow D by + default, Flow A as the fallback.** + + Two host paths put a signed image into MRAM on an Alif Ensemble part. Both + need the SETOOLS `app-gen-toc` step to sign the ATOC; they differ only in + TRANSPORT, and the transport is the part tan owns: + + * **Flow A** -- `zephyr_west_flash` with no runner, so `west flash` picks the + board.cmake default (`alif_flash`) and burns over the SE-UART. Needs a + dedicated 1.8 V-capable USB-UART, which the bench runbook calls the #1 + trap. + * **Flow D** -- `alif_mram_jlink`: J-Link straight over SWD, no SE-UART. Same + blob(s), same addresses, ~0.16 s, and the bench's day-to-day default + (`docs/aen-bench-bringup.md`: "Flow D is the day-to-day default now"). + + The switch is made **entirely from data**, never from silicon knowledge: a + `zephyr_west_flash` entry whose `flash_args` carries `FLOW_D_KEYS` is + dispatched as Flow D instead. tan cannot ask "is this an AEN MRAM part?" -- + that would put a SKU or an address in tan, which ADR-0017 / I-26 forbid and + no gate would catch. What it CAN ask is "did the SoM preset hand me a + part-number J-Link profile for this slice?", because that arriving at all + IS metadata's statement that this silicon has a J-Link MRAM loader. + + Consequence, stated plainly: with today's emit + (`tan/planner/orchestrator.py::_slice_flash_recipe` returns + `("zephyr_west_flash", {})` for every Zephyr slice) NO entry carries that + key, so every AEN slice still takes Flow A. Arming Flow D is now a + one-function change in THIS repo; it is deliberately NOT emulated here by + sniffing the SKU. + """ + method = target.flash_method or None + if method == "zephyr_west_flash" and flow_d_available(target.flash_args): + return FLOW_D_METHOD + return method + + +def parse_atoc_start_address(text: str) -> str | None: + """The ATOC package's MRAM placement out of an `app-gen-toc` + `app-package-map.txt` report -- the LAST `APP Package Start Address:` + line's last field, mirroring every bench script's own + ``awk '/APP Package Start Address:/{print $NF}' app-package-map.txt | tail + -1`` byte for byte (last match wins: a re-signed re-run APPENDS a fresh + block rather than truncating the file, per + `scripts/bench/aen/flash-jlink.sh`/`flash-jlink-mramxip.sh`/ + `flash-update-log-dual.sh`). `None` when the marker never appears -- an + empty, foreign or not-yet-signed file, not a malformed one; the caller + decides what that means. + + **tan-cli#373.** `tan.core.setools.sign_slot0` -- the one place this repo + WRITES that report -- never deletes it for exactly this reason: an + earlier version did, which destroyed every prior entry (this SETOOLS + install's whole accumulated sign history) the moment a soft-failing + re-sign recreated the file holding only its own block. + + **This is a BUILD-TIME output, never plan-time metadata.** `app-gen-toc` + writes the address fresh at signing time and the runbook says outright it + SHIFTS per build/config -- no field under `metadata/**` can express it, so + parsing this report is the only correct source. See `plan_alif_mram_jlink` + for the required/optional split this feeds; the actual file read happens + in `tan.commands.flash_cmd` (IO), never here. + """ + address: str | None = None + for line in text.splitlines(): + if "APP Package Start Address:" not in line: + continue + fields = line.split() + if fields: + address = fields[-1] + return address + + +def is_elf_artefact(artefact: str) -> bool: + """Extension-based, mirroring `is_raw_bin`'s own convention: no extension, + `.elf`, or `.out` (case-insensitive) -- the three "plausibly ELF" shapes + #367(a) named and #353 agreed are safe to resolve automatically to a + same-stem sibling `.bin`. Covers the Zephyr build's own known-good + `zephyr.elf`/`zephyr.bin` pair AND a toolchain output named bare (`app`) + or `.out` with no `.elf` suffix. Every other shape (a `.hex` carries its + own load addresses) is NOT, even when a same-stem `.bin` happens to sit + beside it: that could be an unrelated image, and resolving it silently + would flash something the manifest never named. + + **tan-cli#373.** A prior version of this function accepted `.elf` only -- + a narrowing of #367(a)'s own three-shape decision that nothing flagged, + since no test exercised the other two. + """ + return os.path.splitext(artefact)[1].lower() in ("", ".elf", ".out") + + +def resolve_slot0_binary(artefact: str, is_file: Callable[[str], bool]) -> str | None: + """The ONE definition of "what raw `.bin` does this slot0 write/sign + actually mean" -- shared by [`plan_alif_mram_jlink`] (the `loadbin`/ + `verifybin` pair) and `tan.commands.flash_cmd + ._resolve_flow_d_atoc_via_setools` (the SETOOLS `app-gen-toc` sign + input), via [`validate_flow_d_shape`], so the two can never resolve + DIFFERENT files for the same entry. **#367's root cause**: they used to + each carry their own copy of this logic, and neither actually applied the + ELF-only restriction its own comment/tests claimed -- a `.hex` resolved + to a same-stem sibling `.bin` exactly like an ELF did, silently flashing + a different artefact than the manifest named. + + Already a raw `.bin` -> returned unchanged. A plausibly-ELF artefact + ([`is_elf_artefact`]: no extension, `.elf`, `.out`) with a real + same-directory, same-stem `.bin` -> that sibling. Every other shape -- a + `.hex`, a plausibly-ELF artefact with no sibling, anything else -- -> + `None`; the caller decides how to phrase the refusal. + """ + if is_raw_bin(artefact): + return artefact + if not is_elf_artefact(artefact): + return None + sibling = os.path.splitext(artefact)[0] + ".bin" + return sibling if is_file(sibling) else None + + +@dataclass(frozen=True) +class FlowDShape: + """Everything about a Flow D entry that is knowable WITHOUT `atoc`/ + `atoc_address` -- i.e. before SETOOLS may need to sign one. `artefact` is + already resolved via [`resolve_slot0_binary`] when `app_address` is set; + callers must use THIS value, not the raw input artefact, for both the + SETOOLS sign input and the final loadbin/verifybin. + """ + + device: str + app_address: str | None + artefact: str + + +def validate_flow_d_shape(fa: Any, artefact: str, is_file: Callable[[str], bool]) -> FlowDShape: + """Validate + resolve every Flow D input that does NOT depend on `atoc`/ + `atoc_address` -- the part-number device profile and, when + `slot0_load_address` selects the mramxip shape, the artefact itself. + + **The single source `tan.commands.flash_cmd._flash_entry` now calls + BEFORE ever considering a SETOOLS auto-sign or a `--dry-run` preview + (#366).** `atoc`/`atoc_address` are legitimately still absent at that + point -- SETOOLS is what is about to produce them -- so this cannot + validate the WHOLE entry; splitting out exactly the half that CAN be + checked early means a manifest that would fail here can no longer report + `ok:true` on a SETOOLS `--dry-run` preview just because the failure used + to live only in the (until-then unreached) second half of + `plan_alif_mram_jlink`. + + Also called BY `plan_alif_mram_jlink` itself -- there remains exactly ONE + implementation of these checks; calling it twice on the real-write path + is pure/side-effect-free, so the repeat costs nothing. + """ + device = fa_str_checked(fa, "jlink_flash_device", False) + if device is None: + if _fa_has_key(fa, "jlink_flash_device"): + raise FlashPlanError( + f"{FLOW_D_METHOD}: flash_args.jlink_flash_device is present but " + "null/empty -- refusing to write MRAM with no part-number J-Link " + "device profile; the generic profile has none. It is a per-variant " + "metadata fact (socs/**/*.json `variants[].debug.jlink_flash_device`); " + "tan does not guess a part number." + ) + raise FlashPlanError( + f"{FLOW_D_METHOD}: flash_args.jlink_flash_device is required -- only the " + "part-number J-Link device profile unlocks the MRAM loader, and the " + "generic profile has none. It is a per-variant metadata fact " + "(socs/**/*.json `variants[].debug.jlink_flash_device`); tan does not " + "guess a part number." + ) + validate_identifier(device, "jlink_flash_device") + # OPTIONAL -- selects the mramxip two-blob shape when present; the default + # single-ATOC-blob shape (flash-jlink.sh) needs no app-address write at + # all, since the ATOC embeds the app. `None` only for genuinely absent; a + # present-but-malformed value still raises below, never silently reverts + # to the default shape. + # + # `fa_str_checked` alone cannot tell "key absent" from "key present with a + # null/empty-string value" -- both collapse to `None` (`raw or None` at + # line ~571). A key that IS present must still refuse when it resolves to + # `None`: a `slot0_load_address: ""` or a bare `slot0_load_address:` (YAML + # null) must never silently pick the default shape, exactly like any other + # malformed value. + app_address = fa_str_checked(fa, "slot0_load_address", True) + if app_address is None and _fa_has_key(fa, "slot0_load_address"): + raise FlashPlanError( + f"{FLOW_D_METHOD}: flash_args.slot0_load_address is present but " + "null/empty -- refusing to silently select the default " + "single-ATOC-blob shape. Remove the key entirely to use the default " + "shape, or supply the app's real MRAM address to select the mramxip " + "two-blob shape." + ) + resolved_artefact = artefact + if app_address is not None: + validate_address(app_address, "slot0_load_address") + # The mramxip shape `loadbin`s the app blob at an explicit MRAM + # address -- correct ONLY for a raw `.bin`. `loadbin`ing anything else + # (e.g. `zephyr.elf`) at that address writes the artefact's own + # headers into MRAM instead of the app image (tan-cli#311). Unlike + # `plan_swd_probe`'s ELF/HEX fallback to `loadfile`, there is no + # fallback here: `loadfile` ignores `slot0_load_address` entirely, + # which would silently place the app wherever the ELF's own load + # addresses say rather than where this flow demands -- a refusal is + # the safer failure. tan-cli#353 resolves a real ELF/sibling-`.bin` + # pair rather than refusing over something resolvable; + # [`resolve_slot0_binary`] is the ONE place that decides which shapes + # qualify (#367). + resolved = resolve_slot0_binary(artefact, is_file) + if resolved is None: + if is_elf_artefact(artefact): + detail = ( + "No sibling " + f"{os.path.basename(os.path.splitext(artefact)[0] + '.bin')} " + "was found beside it either." + ) + else: + detail = ( + "Only a plausibly-ELF artefact's (no extension, .elf, .out) " + "same-stem sibling .bin is resolved automatically -- a .hex " + "(or any other shape) is not, even with a same-stem .bin " + "beside it, since that could be an unrelated image." + ) + raise FlashPlanError( + f"{FLOW_D_METHOD}: flash_args.slot0_load_address is set but the " + f"artefact {artefact} is not a raw .bin -- refusing to loadbin " + "it at slot0_load_address, which would write the artefact's own " + f"headers into MRAM instead of the app image. {detail} Point the " + "build's output_artefact at the slot0-linked zephyr.bin for the " + "mramxip shape." + ) + resolved_artefact = resolved + # tan-cli#373: `jlink_speed`/`confirm` do not depend on `atoc`/`atoc_address` + # either, so they belong in the "everything checkable early" half this + # function IS -- but #366 only moved `jlink_flash_device`/ + # `slot0_load_address` here, leaving these two still validated only deep + # inside `plan_alif_mram_jlink`. That left #366's own fix narrowed, not + # closed: `_resolve_flow_d_atoc_via_setools`'s `--dry-run` preview short- + # circuits BEFORE `plan_alif_mram_jlink` ever runs (measured: a manifest + # with `jlink_speed: "fast"` and SETOOLS resolving reported `ok:true` + # under `--dry-run`), and on a REAL (confirmed) run the SETOOLS auto-sign + # itself -- spawning `app-gen-toc`, writing into the customer's install -- + # happens before `plan_alif_mram_jlink` ever gets a chance to refuse. + # Validating (and discarding the result -- `plan_alif_mram_jlink` still + # applies its own default) here closes that gap regardless of which path + # the entry takes afterward. + fa_int_checked(fa, "jlink_speed") + fa_bool_checked(fa, "confirm") + return FlowDShape(device=device, app_address=app_address, artefact=resolved_artefact) + + +def plan_alif_mram_jlink(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan: + """Flow D: burn the signed ATOC into MRAM over SWD with J-Link's built-in + Alif MRAM loader, verify it, then PIN-reset so the Secure Enclave boot ROM + boots the image -- the same blob(s) at the same addresses SETOOLS writes + over the SE-UART, so no re-signing and no keys. + + **Two shapes, selected from data, matching the two bench scripts they + port** (`scripts/bench/aen/flash-jlink.sh` / `flash-jlink-mramxip.sh`): + + * **Default -- single ATOC blob.** The day-to-day flow + (`flash-jlink.sh`): the ATOC is self-contained (an ITCM-load package, + its own embedded load address set by `app-gen-toc`), so ONE + `loadbin`/`verifybin` of `atoc` at `atoc_address` is the whole write. + This is what runs whenever `flash_args` omits `slot0_load_address`. + * **mramxip -- two blobs.** The ITCM-overflow exception + (`flash-jlink-mramxip.sh`), for an app LINKED into MRAM slot0 (built + with `CONFIG_USE_DT_CODE_PARTITION=y`, a per-app-build opt-in tan does + not set): the app blob itself also needs writing, to `slot0_load_address`, + ahead of the ATOC. This activates only when `flash_args.slot0_load_address` + is present -- tan cannot detect the Kconfig opt-in from here, so a + manifest that arms the mramxip shape must supply the address that + proves it was built that way. + + **Every identifier is read from `flash_args`; none is baked in.** Required + in both shapes: + + * `jlink_flash_device` -- the PART-NUMBER device profile. Only this unlocks + the loader; with a generic `Cortex-M55` profile there is no loader and + `loadbin` to MRAM does nothing useful. It is also the wrong profile for + attaching to a live core, which is why it is a distinct metadata key + (`jlink_flash_device`, not `jlink_device`) on the SoC spec. + * `atoc` + `atoc_address` -- the signed ATOC blob and its MRAM placement. + The address SHIFTS per build/config and the runbook says outright not to + hardcode it -- it is a BUILD-TIME output of the signing step, never a + metadata fact, so this function still requires it as a plain + `flash_args` value and REFUSES when it is absent. tan does NOT run + `app-gen-toc` here either way: signing is common to both flows and + belongs to whatever produced the ATOC. What changed is only WHO fills + `atoc_address` in before this function runs -- `tan.commands.flash_cmd` + resolves it from `flash_args.atoc_map` (an `app-package-map.txt` path) + via `parse_atoc_start_address` when the manifest supplies that instead + of a baked-in address, so a customer's manifest never has to hardcode a + value that changes every build. `atoc` itself is read here VERBATIM -- + this module has no filesystem access to resolve it against -- so + `tan.commands.flash_cmd` also anchors it on `build_root`/`sdk_root` + (`resolve_artefact_path`, the same resolver `output_artefact`/ + `atoc_map` use) before this function ever sees it; a caller that skips + that step hands this function a path relative to WHATEVER the eventual + spawn's cwd turns out to be, not the build root. + + Optional, mramxip-only: + + * `slot0_load_address` -- where the slot0-linked app itself sits, so the SE boots + it in place rather than loading it out of the ATOC. Present but + malformed is still a loud refusal, never a silent fall-back to the + default shape -- a quoting detail must never decide which shape burns. + + Absent a required identifier this REFUSES. There is no default to fall + back to: a guessed MRAM address is a write to the wrong place on a part + whose Secure Enclave then boots whatever is there. + + Confirm-gated (`flash_args.confirm` OR `ALP_FLASH_FORCE=1`) like the other + two persistent-device backends -- see the `planning_only` note below. + """ + fa = inp.flash_args + shape = validate_flow_d_shape(fa, inp.artefact, os.path.isfile) + device, app_address, artefact = shape.device, shape.app_address, shape.artefact + + atoc = fa_str(fa, "atoc") + atoc_address = fa_str_checked(fa, "atoc_address", True) + if atoc is None or atoc_address is None: + raise FlashPlanError( + f"{FLOW_D_METHOD}: flash_args.atoc (the signed ATOC blob) and " + "flash_args.atoc_address are both required. Both flows burn the SAME " + "signed ATOC -- sign it with the SETOOLS `app-gen-toc` step and pass the " + "blob plus the placement its own report prints; the addresses shift per " + "build and must not be hardcoded." + ) + validate_address(atoc_address, "atoc_address") + + speed = _default(fa_int_checked(fa, "jlink_speed"), _DEFAULT_JLINK_SPEED) + # Probe serial: the ONLY disambiguator when a bench carries more than one + # J-Link. No default -- a bench-wide serial can be shared by two probes that + # differ only by USB path, and a silent default can select the wrong board. + serial = fa_str(fa, "jlink_serial") + # The expected SW-DP IDR. When the manifest supplies one, the Commander + # script connects with the READ profile first and the caller ABORTS unless + # that ID appears -- writing MRAM on the wrong attached board is the one + # unrecoverable mistake this path can make. A hardware value, so it comes + # from data: tan neither knows nor invents an IDR. + expect_dpidr = fa_str_checked(fa, "expect_dpidr", False) + if expect_dpidr is not None: + validate_address(expect_dpidr, "expect_dpidr") + + jlink = _JLINK_BINARIES[0] if inp.dry_run else next( + (n for n in _JLINK_BINARIES if which(n)), None + ) + if jlink is None: + raise FlashPlanError( + f"{FLOW_D_METHOD}: needs SEGGER J-Link on PATH (JLinkExe/JLink), on a " + "V9.46+ DLL with the probe on matched firmware -- the built-in Alif MRAM " + "loader ships with the DLL and older ones cannot connect with the " + "part-number device profile." + ) + + # Two-blob mramxip shape only when `slot0_load_address` armed it; otherwise the + # default single-ATOC-blob shape (flash-jlink.sh) writes nothing for the + # app -- the ATOC already embeds it. See the docstring's "two shapes" note. + lines: list[str] = [] + if serial is not None: + lines.append(f"SelectEmuBySN {serial}") + else: + # tan-cli#353: no serial pinned, so this script selects no probe. Fine + # on a single-probe host; on a bench with several J-Links JLinkExe + # cannot choose and answers "Connecting to J-Link ...FAILED: Cannot + # connect to the probe/programmer." -- measured on the AEN bench, which + # carries three. Recorded here so the failure diagnosis can SAY that + # instead of leaving the user with SEGGER's bare sentence; the plan + # itself is unchanged, because refusing would break every correct + # single-probe host. + pass + # Quoted (tan-cli#369) ONLY for these Commander-script lines, when either + # actually contains whitespace -- `artefact`/`atoc` themselves are left + # unquoted for `ok_message` and every other use above. + commander_artefact = commander_path(artefact) + commander_atoc = commander_path(atoc) + lines += ["si SWD", f"speed {speed}", f"device {device}", "connect"] + if app_address is not None: + # `artefact`, not `inp.artefact`: the tan-cli#353 sibling resolution + # above may have swapped an ELF for its real raw `.bin`, and the + # write must use what was RESOLVED or the guard would be decorative. + lines.append(f"loadbin {commander_artefact} {app_address}") + lines.append(f"loadbin {commander_atoc} {atoc_address}") + if app_address is not None: + lines.append(f"verifybin {commander_artefact} {app_address}") + lines += [ + f"verifybin {commander_atoc} {atoc_address}", + # PIN reset (RSetType 2), then run: the Secure Enclave boot ROM re-reads + # and boots the ATOC, exactly as it does after an SE-UART burn. A core + # reset would leave the SE out of the loop. + "RSetType 2", + "r", + "g", + "exit", + ] + argv = ( + jlink, "-device", device, "-if", "SWD", "-speed", str(speed), + "-ExitOnError", "1", "-NoGui", "1", "-CommanderScript", + ) + confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False) + ok_message = ( + f"{FLOW_D_METHOD}[{inp.core_id}]: app -> {app_address}, signed ATOC -> " + f"{atoc_address} via J-Link ({device}); verified and PIN-reset" + if app_address is not None + else ( + f"{FLOW_D_METHOD}[{inp.core_id}]: signed ATOC (app embedded) -> " + f"{atoc_address} via J-Link ({device}); verified and PIN-reset" + ) + ) + return FlashPlan( + argv=argv, + ok_message=ok_message, + # `planning_only` -- and therefore the `planned` status + the + # `flash.confirm-required` warning -- for an UNCONFIRMED run, matching + # `yocto_wic`/`xspi_flashwriter`. This is a NEW backend, so nothing in + # the oracle is being diverged from, and it is the only backend in the + # registry that persistently programs on-die MRAM: a `tan flash` in a + # fresh customer's checkout must not silently reprogram an attached + # module. `swd_probe` is ungated for a reason that does not apply here + # (it targets an external helper MCU's own flash). + planning_only=inp.dry_run or not confirm, + jlink_script="\n".join(lines) + "\n", + ) + + +def validate_flow_d_preflight_args(flash_args: Any) -> tuple[str | None, str | None]: + """The presence/pairing/shape checks for Flow D's DPIDR preflight, + returning `(expect_dpidr, jlink_device)` -- both `None` (opted out) or + both set (validated). Raises `FlashPlanError` for every half-armed or + malformed shape; never touches a J-Link binary or builds the Commander + script, so the CALLER decides when to run it. `flow_d_preflight_script` + (write-path) runs it then builds the script from the same values; + `tan.commands.flash_cmd` also runs it PLAN-TIME, before the confirm/ + dry-run gate, so a half-armed or malformed manifest surfaces as a + `flash.entry-failed` issue in the planned envelope too -- not only at + real-write time. + + `None`/`None` only for a genuinely ABSENT `expect_dpidr`/`jlink_device` -- + the documented, test-pinned way to opt out of the preflight entirely. A + key that IS present must still refuse when it resolves to `None` + (`fa_str_checked` alone cannot tell "absent" from "present but + null/empty"; see `_fa_has_key`'s docstring): silently treating it as + absent would drop the SW-DP IDR check -- the one guard standing between a + wrong-board attach and an MRAM write -- with no diagnostic at all. + """ + fa = flash_args + expected = fa_str_checked(fa, "expect_dpidr", False) + if expected is None and _fa_has_key(fa, "expect_dpidr"): + raise FlashPlanError( + f"{FLOW_D_METHOD}: flash_args.expect_dpidr is present but null/empty -- " + "refusing to silently skip the pre-write SW-DP IDR check. Remove the key " + "entirely to skip the preflight, or supply the board's real expected ID." + ) + read_device = fa_str_checked(fa, "jlink_device", False) + if read_device is None and _fa_has_key(fa, "jlink_device"): + raise FlashPlanError( + f"{FLOW_D_METHOD}: flash_args.jlink_device is present but null/empty -- " + "refusing to silently skip the pre-write SW-DP IDR check. Remove the key " + "entirely to skip the preflight, or supply the live-core read device " + "profile." + ) + # Half-armed by a genuinely ABSENT partner key (not a null one -- that is + # the two checks above): supplying `expect_dpidr` is the manifest's + # unambiguous statement that it wanted the wrong-board guard armed, and + # the reverse holds for `jlink_device`. Silently returning `None` here + # would drop the SW-DP IDR check with no diagnostic at all, immediately + # before the one write this backend's own docstring calls unrecoverable. + if (expected is None) != (read_device is None): + present_key, absent_key = ( + ("expect_dpidr", "jlink_device") + if expected is not None + else ("jlink_device", "expect_dpidr") + ) + raise FlashPlanError( + f"{FLOW_D_METHOD}: flash_args.{present_key} is present but flash_args." + f"{absent_key} is not -- refusing to silently skip the pre-write SW-DP " + "IDR check. Supply both flash_args.expect_dpidr and flash_args." + "jlink_device to arm the preflight, or remove both to skip it entirely." + ) + if expected is None or read_device is None: + return None, None + validate_address(expected, "expect_dpidr") + validate_identifier(read_device, "jlink_device") + return expected, read_device + + +def flow_d_preflight_script(inp: FlashInputs) -> tuple[str, str] | None: + """The read-only DPIDR preflight for a Flow D plan: `(script, expected_id)`, + or `None` when the manifest declared neither `expect_dpidr` nor + `jlink_device` at all -- see `validate_flow_d_preflight_args` for every + other case, which this delegates to before building the script. + + Run BEFORE any write, with the manifest's READ device profile (a live-core + attach profile, which the part-number one is not), so the caller can abort on + the wrong board while the session is still read-only. Both the device name + and the expected ID come from `flash_args`. + """ + expected, read_device = validate_flow_d_preflight_args(inp.flash_args) + if expected is None or read_device is None: + return None + fa = inp.flash_args + speed = _default(fa_int_checked(fa, "jlink_speed"), _DEFAULT_JLINK_SPEED) + lines = [] + serial = fa_str(fa, "jlink_serial") + if serial is not None: + lines.append(f"SelectEmuBySN {serial}") + lines += ["si SWD", f"speed {speed}", f"device {read_device}", "connect", "exit"] + return "\n".join(lines) + "\n", expected + + +_REGISTRY: dict[str, BackendMeta] = { + "swd_probe": BackendMeta(("JLinkExe", "JLink", "openocd", "pyocd"), plan_swd_probe), + "zephyr_west_flash": BackendMeta(("west",), plan_zephyr_west_flash), + "baremetal_cmake_flash": BackendMeta(("cmake",), plan_baremetal_cmake_flash), + "yocto_wic_to_sd_or_emmc": BackendMeta(("bmaptool", "dd"), plan_yocto_wic), + "yocto_wic": BackendMeta(("bmaptool", "dd"), plan_yocto_wic), + "xspi_flashwriter": BackendMeta((), plan_xspi_flashwriter), + FLOW_D_METHOD: BackendMeta(("JLinkExe", "JLink"), plan_alif_mram_jlink), +} + + +# ── the required-tool gate ────────────────────────────────────────────────── + +PROCEED = "proceed" +SKIP = "skip" +FAIL = "fail" + + +@dataclass(frozen=True) +class ToolGate: + outcome: str + message: str = "" + + +def tool_gate( + requires, + dry_run: bool, + skip_missing: bool, + kind: str, + entry_id: str, + method: str, + which: Callable[[str], bool], +) -> ToolGate: + """A backend is usable when AT LEAST ONE of `requires` is on PATH. Bypassed + entirely under `--dry-run`, and for a backend with an empty `requires`.""" + if dry_run or not requires: + return ToolGate(PROCEED) + if any(which(t) for t in requires): + return ToolGate(PROCEED) + msg = ( + f"flash: {kind} '{entry_id}' backend '{method}' needs one of " + f"{_str_list_debug(requires)} on PATH; none found." + ) + if skip_missing: + return ToolGate(SKIP, f"{msg} (skipped via --skip-missing-tools)") + return ToolGate(FAIL, msg) + + +# ── argv display ──────────────────────────────────────────────────────────── + + +def display_argv(plan: FlashPlan) -> str: + """The would-run display string; a J-Link plan shows a `` + placeholder for the temp Commander script (which does not exist yet, and + whose name carries a pid + nanosecond stamp that must never reach a + golden).""" + parts = list(plan.argv) + if plan.jlink_script is not None: + parts.append("") + return " ".join(parts) diff --git a/python/tan/core/global_flags.py b/python/tan/core/global_flags.py new file mode 100644 index 00000000..b3436cd4 --- /dev/null +++ b/python/tan/core/global_flags.py @@ -0,0 +1,178 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The single place a command opts into the oracle's global-argument surface +(tan-cli#261). + +The v0.4.1 oracle's clap `GlobalArgs` (`crates/tan-cli/src/cli.rs` lines +24-73) marks every field `#[arg(long, global = true, ...)]`: clap attaches +the WHOLE struct to every subcommand, so `tan --verbose` parses +on the oracle even for a command whose own Rust handler never reads +`args.verbose`. Measured against this port (tan-cli#261, re-measured before +this file existed): 99 registration sites across 17 already-ported commands +raised Click's own "No such option" instead -- not because the flag did the +wrong thing, but because nobody had declared it there at all, an +eighteenth-command-repeats-the-mistake defect this module exists to remove +structurally rather than patch site by site. + +`accept_global_flags` closes exactly that gap and nothing else: it adds a +`typer.Option` for whichever of `_GLOBAL_FLAG_SPECS` a command's own +signature does not ALREADY declare, detected by the CLI flag STRING itself +(each parameter's `typer.Option(...).param_decls`) rather than by Python +parameter name, which already varies command to command for the identical +flag (`all_cores` in `clean_cmd.clean`, `all_targets` in `build_cmd.build`, +both `--all`). A flag a command already reads for real keeps doing exactly +that -- this module never touches an existing parameter. A flag added here is +accepted and then DROPPED before the wrapped command ever runs, so a +command's own body is never handed a value it was not already written to +expect: accepting is not the same as reading, and this module only ever does +the former for a flag it adds. + +This is the same "declared once, accepted everywhere, read only where a +command already reads it" shape `clean_cmd.clean` hand-wrote for six flags +before this module existed -- see its own comment there: "the shared fix +(one decorator for every command) belongs with whoever owns the global-flag +surface." This is that decorator, generalised and applied to the rest of the +surface. + +`--format` is deliberately NOT one of `_GLOBAL_FLAG_SPECS`, matching +`cli.py`'s own `_HONOURS_ROOT_FORMAT`: unlike every flag here, a command that +merely ACCEPTED `--format json` without reading it would silently run in text +mode for a caller who asked for JSON -- exactly the defect +`_HONOURS_ROOT_FORMAT` exists to prevent by refusing until a command is +actually taught to read `ctx.obj["format"]`. None of the ten flags below have +that failure mode: an accepted-and-ignored `--verbose` changes nothing about +which channel a caller reads, so silently dropping it is safe where silently +dropping `--format json` would not be. +""" +from __future__ import annotations + +import inspect +import typing +from collections.abc import Callable + +import typer + +#: One entry per oracle `GlobalArgs` field this port must be able to PARSE on +#: every command (`crates/tan-cli/src/cli.rs:24-73`), as +#: `(flag, python_name, is_bool, metavar)`. `metavar` is `None` for a bool +#: flag (arity 0). `--project`/`--sdk-root` are included even though every +#: command touched by tan-cli#261 already declares them (measured) -- the +#: NEXT command to be added is exactly who this guards. +_GLOBAL_FLAG_SPECS: tuple[tuple[str, str, bool, str | None], ...] = ( + ("--project", "project", False, "PATH"), + ("--board-yaml", "board_yaml", False, "PATH"), + ("--sdk-root", "sdk_root", False, "PATH"), + ("--target", "target", False, "EMIT"), + ("--all", "all_", True, None), + ("--verbose", "verbose", True, None), + ("--quiet", "quiet", True, None), + ("--no-color", "no_color", True, None), + ("--non-interactive", "non_interactive", True, None), + ("--ci", "ci", True, None), +) + +#: `{flag: arity}` -- `cli.py`'s `_reorder_global_flags` reads this (arity 1 +#: = takes a value, 0 = boolean) to relocate a leading global flag across the +#: subcommand boundary. Derived from `_GLOBAL_FLAG_SPECS` so the reorder +#: table and this module's own injection list cannot drift apart the way +#: tan-cli#261's own second comment flagged `_GLOBAL_FLAG_ARITY` could. +GLOBAL_FLAG_ARITY: dict[str, int] = { + flag: (0 if is_bool else 1) for flag, _name, is_bool, _metavar in _GLOBAL_FLAG_SPECS +} + +#: Every flag this module knows how to inject -- what the port-wide gate +#: (`tests/gates/test_global_flags_gate.py`) walks to assert the whole +#: registered command surface accepts it. +GLOBAL_FLAGS: tuple[str, ...] = tuple(flag for flag, *_rest in _GLOBAL_FLAG_SPECS) + +#: Shown nowhere (every injected option is `hidden=True`, matching the +#: precedent `clean_cmd.clean` already set for its six) -- kept as a real +#: string anyway so a `--help -v` or future un-hiding does not surface a bare +#: `None`. +_ACCEPTED_NOT_READ_HELP = "Accepted for oracle parity (tan-cli#261); not read by this command." + + +def _declared_flags(func: Callable[..., object]) -> set[str]: + """Every CLI flag string `func` already declares, keyed by the flag + string itself rather than by Python parameter name -- the same fact + under a different name (`all_cores` vs `all_targets` for `--all`) must + still count as "already declared", or this would hand a command a + second, silently-ignored `--all` behind its own real one.""" + declared: set[str] = set() + for param in inspect.signature(func).parameters.values(): + decls = getattr(param.default, "param_decls", None) + if decls: + declared.update(decls) + return declared + + +def accept_global_flags(func: Callable[..., object]) -> Callable[..., object]: + """Return a callable Typer can register whose declared options are + `func`'s own plus whichever of `_GLOBAL_FLAG_SPECS` it was missing, each + of the added ones accepted and then dropped before `func` ever runs. + + A command that already declares the full set is returned UNCHANGED + (`func` itself, not a wrapper) -- the common case once every command in + tan-cli#261 has been swept once. + + Call this right where the command function is defined, in its own + `*_cmd.py` module (`validate = accept_global_flags(validate)`), not as a + decorator on the `app.command(...)` call in `cli.py`: by the time + `cli.py` runs, the click `Command` Typer builds from the function is + already final, and wrapping there would scatter the fix away from the + command it changes. + """ + sig = inspect.signature(func) + existing = _declared_flags(func) + to_add = [spec for spec in _GLOBAL_FLAG_SPECS if spec[0] not in existing] + if not to_add: + return func + + # Resolve every EXISTING parameter's annotation to the real type object, + # not whatever bare STRING `from __future__ import annotations` leaves it + # as: `inspect.signature` alone never evaluates PEP 563 postponed + # annotations, and every one of these 17 `*_cmd.py` modules has that + # import at the top. Typer normally resolves the string itself via + # `typing.get_type_hints(callback)`, using `callback.__globals__` -- + # `func`'s module, where `str`/`bool`/`typer.Context`/... are in scope. + # Once `func` is wrapped below, Typer would instead resolve hints + # against `wrapper.__globals__` -- THIS module's globals, not `func`'s -- + # and get nothing back for a name it cannot see; the fallback path then + # feeds `get_click_type` the literal string `'str'` instead of the type + # `str`, which fails with `RuntimeError: Type not yet supported: str` + # (measured: `explain --target zephyr-board` after a first version of + # this function skipped this step). Resolving here, once, against the + # ORIGINAL `func` -- exactly what Typer would have done directly -- is + # what keeps a wrapped command's pre-existing options working at all. + resolved_hints = typing.get_type_hints(func) + params = [ + param.replace(annotation=resolved_hints[name]) if name in resolved_hints else param + for name, param in sig.parameters.items() + ] + + injected: list[str] = [] + for flag, name, is_bool, metavar in to_add: + if is_bool: + option = typer.Option(False, flag, hidden=True, help=_ACCEPTED_NOT_READ_HELP) + annotation: type = bool + else: + option = typer.Option( + None, flag, metavar=metavar, hidden=True, help=_ACCEPTED_NOT_READ_HELP + ) + annotation = str + params.append( + inspect.Parameter( + name, inspect.Parameter.KEYWORD_ONLY, default=option, annotation=annotation + ) + ) + injected.append(name) + + def wrapper(*args: object, **kwargs: object) -> object: + for name in injected: + kwargs.pop(name, None) + return func(*args, **kwargs) + + wrapper.__doc__ = func.__doc__ + wrapper.__name__ = getattr(func, "__name__", "wrapper") + return_annotation = resolved_hints.get("return", sig.return_annotation) + wrapper.__signature__ = inspect.Signature(params, return_annotation=return_annotation) + return wrapper diff --git a/python/tan/core/module_template.py b/python/tan/core/module_template.py new file mode 100644 index 00000000..c10d0021 --- /dev/null +++ b/python/tan/core/module_template.py @@ -0,0 +1,237 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Module-scaffold template registry, name normalization, and file-content +generators for `tan scaffold` -- adds one driver/service/stage/check module +INTO an existing project. Distinct from `tan.core.scaffold`, which is the +whole-*project* `tan init` engine (a different template id space: six +project templates there vs. the four module templates here, and neither +registry's ids overlap the other's). + +Port of `crates/tan-core/src/wizard/{models,service/registry,service/plan, +service/module_scaffold}.rs`'s module-scaffold slice only -- the project- +wizard half of those same files is `tan.core.scaffold`'s job. + +Diffing planned files against disk, writing them, and rendering the ASCII +tree preview are deliberately NOT re-implemented here: `tan.core.scaffold`'s +`PlannedFile` / `collect_file_changes` / `write_files` / `scaffold_tree_preview` +already do exactly that -- module-scaffold's planned files are the SAME +`PlannedFile` shape a whole-project plan uses, and `write_files` already +carries the tan-cli#325 containment fix (`tan.core.fs_confine.resolve_confined`). +A second copy of any of the four here would be exactly the drift +`tan.core.fs_confine`'s own module docstring warns a THIRD hand-rolled +resolver into being. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from tan.core.scaffold import PlannedFile + +#: Module template ids, in registry order (`ModuleTemplateId::as_str`, +#: `wizard/models.rs`). Wire contract: `data.templateId` echoes one of these +#: verbatim. +MODULE_TEMPLATE_IDS = ( + "sensor-driver", + "connectivity-service", + "inference-stage", + "diagnostics-check", +) + +#: The template a non-interactive `tan scaffold` with no `--template` gets -- +#: `resolve_template`'s non-interactive arm in `crates/tan-cli/src/commands/ +#: scaffold.rs` hardcodes `ModuleTemplateId::SensorDriver`, the registry's +#: first entry. Unlike `tan.core.scaffold.DEFAULT_TEMPLATE_ID` (`tan init`'s +#: own default), this one has no reason to diverge from "first in the list": +#: there is no `CMakeLists.txt`-shaped trap here, every module template emits +#: the same three-file shape. +DEFAULT_MODULE_TEMPLATE_ID = "sensor-driver" + + +@dataclass(frozen=True) +class ModuleTemplateDefinition: + """One module-template registry entry. `explanation` lines may contain a + literal `{nm}` placeholder, substituted with the normalized module name + when a module's `README.md` is rendered (see `_readme`, below).""" + + id: str + label: str + description: str + function_prefix: str + explanation: tuple[str, ...] + + +#: `MODULE_TEMPLATE_DEFINITIONS` (`wizard/service/registry.rs`), ported +#: verbatim -- same four ids, same order, same prose. +_REGISTRY: tuple[ModuleTemplateDefinition, ...] = ( + ModuleTemplateDefinition( + id="sensor-driver", + label="Sensor driver module", + description="Adds a source/header pair for sensor acquisition logic.", + function_prefix="alp_sensor", + explanation=( + "Use {nm}_run to place sensor polling and conversion logic.", + "Keep hardware-specific register access isolated from upper-level app flow.", + ), + ), + ModuleTemplateDefinition( + id="connectivity-service", + label="Connectivity service module", + description="Adds module skeleton for network/session orchestration.", + function_prefix="alp_conn", + explanation=( + "Use {nm}_init for stack/session initialization.", + "Keep retry/backoff and transport health checks localized in this module.", + ), + ), + ModuleTemplateDefinition( + id="inference-stage", + label="Inference stage module", + description="Adds module skeleton for model pre/post processing path.", + function_prefix="alp_infer", + explanation=( + "Use {nm}_run to host pre-process, infer, and post-process calls.", + "Keep model IO shaping and feature extraction close to this module boundary.", + ), + ), + ModuleTemplateDefinition( + id="diagnostics-check", + label="Diagnostics check module", + description="Adds bring-up and runtime health-check module scaffold.", + function_prefix="alp_diag", + explanation=( + "Use {nm}_run for periodic health checks and error probes.", + "Keep board bring-up assertions and diagnostics output in this module.", + ), + ), +) + +_BY_ID = {d.id: d for d in _REGISTRY} + + +def list_module_templates() -> list[ModuleTemplateDefinition]: + """All registered module-scaffold templates, in registry order.""" + return list(_REGISTRY) + + +def normalize_module_name(name: str) -> str: + """Lowercase `name` and collapse every run of non-`[a-z0-9]` characters + (after lowering) into a single `_` separator, with none leading or + trailing. Raises `ValueError` -- its message is `scaffold.invalid-name`'s + wire text verbatim -- when nothing survives. + + `wizard::service::plan::normalize_module_name`, ported: Rust lowercases + with the full Unicode mapping, then keeps only `is_ascii_lowercase() || + is_ascii_digit()` chars, treating everything else (accented letters + included, even after they lower) as a separator run. Python's `.lower()` + is equally Unicode-aware, so the same two-step -- lower, then filter to + ASCII alnum -- reproduces it byte-for-byte (measured against the oracle: + `"Héllo--World123"` -> `"h_llo_world123"`, the accented `é` collapsing + into the same run as the double dash beside it). + """ + lowered = name.strip().lower() + out: list[str] = [] + in_sep = False + for ch in lowered: + if ("a" <= ch <= "z") or ("0" <= ch <= "9"): + if in_sep and out: + out.append("_") + out.append(ch) + in_sep = False + else: + in_sep = True + result = "".join(out) + if not result: + raise ValueError("Module name is empty after normalization.") + return result + + +def _header(prefix: str, nm: str) -> str: + """`gen_module_header`, ported. `nm` is always already-normalized ASCII + lowercase/digits/underscore, so a plain `.upper()` reproduces Rust's + `nm.to_uppercase()` exactly -- no non-ASCII input ever reaches here.""" + upper = nm.upper() + return ( + "// SPDX-License-Identifier: Apache-2.0\n" + "\n" + f"#ifndef ALP_MODULES_{upper}_H\n" + f"#define ALP_MODULES_{upper}_H\n" + "\n" + f"int {prefix}_{nm}_init(void);\n" + f"int {prefix}_{nm}_run(void);\n" + "\n" + f"#endif /* ALP_MODULES_{upper}_H */\n" + ) + + +def _source(prefix: str, nm: str) -> str: + """`gen_module_c`, ported.""" + return ( + "// SPDX-License-Identifier: Apache-2.0\n" + "\n" + f'#include "modules/{nm}.h"\n' + "\n" + "// Board context: unavailable\n" + "\n" + f"int {prefix}_{nm}_init(void) {{\n" + " // TODO: initialize module dependencies.\n" + " return 0;\n" + "}\n" + "\n" + f"int {prefix}_{nm}_run(void) {{\n" + " // TODO: implement module main behavior.\n" + " return 0;\n" + "}\n" + ) + + +def _readme(definition: ModuleTemplateDefinition, nm: str) -> str: + """`gen_module_readme`, ported.""" + lines = "".join(f"- {line.replace('{nm}', nm)}\n" for line in definition.explanation) + return ( + "# Alp Module Scaffold\n" + "\n" + f"Template: {definition.id}\n" + f"Module: {nm}\n" + "\n" + "## Notes\n" + "\n" + f"{lines}" + "\n" + "Generated by Alp: Scaffold module.\n" + ) + + +def plan_module_files(definition: ModuleTemplateDefinition, nm: str) -> list[PlannedFile]: + """The three files a module template lays down: a header, its source, and + a README. `gen_module_files`, ported -- same relative paths, same order + (the order the oracle's own `data.fileChanges[]` lists them in, and what + `contract`-style byte-for-byte JSON comparison depends on).""" + return [ + PlannedFile(f"include/modules/{nm}.h", _header(definition.function_prefix, nm)), + PlannedFile(f"src/modules/{nm}/{nm}.c", _source(definition.function_prefix, nm)), + PlannedFile(f"src/modules/{nm}/README.md", _readme(definition, nm)), + ] + + +@dataclass(frozen=True) +class ModuleScaffoldPlan: + """`ModuleScaffoldPlan`, ported: the resolved template plus the module's + normalized name and its planned files.""" + + template_id: str + normalized_name: str + files: list[PlannedFile] + + +def create_module_scaffold_plan(template_id: str, module_name: str) -> ModuleScaffoldPlan: + """Normalize `module_name`, then plan its three-file set against + `template_id`. Raises `ValueError` when the name normalizes to empty + (`normalize_module_name`); a `template_id` outside `MODULE_TEMPLATE_IDS` + is a caller bug, not a user error -- `scaffold_cmd` validates it against + the registry BEFORE calling this, so `KeyError` here would mean that + validation was skipped. `create_module_scaffold_plan`, ported. + """ + normalized = normalize_module_name(module_name) + definition = _BY_ID[template_id] + files = plan_module_files(definition, normalized) + return ModuleScaffoldPlan(template_id=template_id, normalized_name=normalized, files=files) diff --git a/python/tan/core/plan_tokens.py b/python/tan/core/plan_tokens.py index 1033106b..e7839f7a 100644 --- a/python/tan/core/plan_tokens.py +++ b/python/tan/core/plan_tokens.py @@ -1,440 +1,440 @@ -# SPDX-License-Identifier: Apache-2.0 -"""Pure build-plan token substitution (alp-sdk #865, "hermetic build plans"). - -A **tokened** plan (`planPathMode: "tokened"`) carries literal placeholders -- -`${SDK_ROOT}` / `${PROJECT_ROOT}` / `${PYTHON}` / `${TOOLCHAIN_ROOT}` -- in its -path-bearing string fields instead of baking in the emitting machine's -absolute paths. This module is the CONSUMER side: ONE blind string- -substitution pass swapping the tokens for tan's already-resolved values, plus -the guards that keep a wrong substitution from silently building the wrong -image. - -Pure -- no IO. The caller (`tan.commands.build.token_substitution`) resolves -the SDK checkout root, the project root (the `board.yaml` directory), the -planner-venv Python and the toolchain root exactly ONCE and hands them in as -`TokenValues`; it also owns invoking `git` for the `sdkCommit` check -(`sdk_commit_mismatches` here only compares strings). - -A plan without `planPathMode: "tokened"` -- every plan the SDK emits today -- -is untouched by `substitute_plan_tokens`: a byte-identical no-op. - -tan-cli #89: an unresolved `${TOOLCHAIN_ROOT}` splits into two outcomes -depending on WHERE it survives substitution. `boardYaml` and -`sharedArtefacts[]` have no owning slice -- no dispatch seam to route a -skip/fail decision to -- so they keep the hard `UnresolvedToolchainRoot` this -pass always raised. A slice field, though, has an owning slice AND an owning -dispatch seam (`executionPolicy.missingTool`, the same knob a missing -`bitbake` already uses): this is a HOST-provisioning fact, not a plan/version -bug, so this pass reports it as a `DemotedSlice` instead of erroring the -whole plan, leaving the skip-vs-fail call to the caller at dispatch time. -`LeftoverToken` is unaffected either way -- an unknown token is a -version/bug fact, never demoted, always plan-fatal. - -Substituting an unresolved token with an empty string would sail past the -leftover-token guard and build against the wrong tree -- so every unresolved -token is REFUSED (or demoted, per the rule above), never degraded to "". - -Ported field-for-field from `crates/tan-core/src/plan_tokens.rs` -(`substitute_plan_tokens` / `substitute_slice` / `substitute_command_lenient` -/ `substitute_artefact` / `substitute_artefact_lenient`), with one field the -Rust `BuildSlice` does not carry: `slices[].appDir`, which the Python -`Slice` (`tan.core.build_plan`) does have. It is substituted immediately -after `buildDir` -- the other slice-level bare path field -- and, like every -other slice field, participates in TOOLCHAIN_ROOT demotion / LeftoverToken -the same as its siblings. `appDir` is nullable per the SDK schema (a Yocto -slice built from the stock-image token has none) -- `None` passes through -untouched, the same as `command.cwd`. -""" -import sys -from dataclasses import dataclass, replace -from typing import Any - -from tan.core.build_plan import BuildPlan, Slice, SliceCommand - -PLAN_PATH_MODE_TOKENED = "tokened" - -TOKEN_SDK_ROOT = "${SDK_ROOT}" -TOKEN_PROJECT_ROOT = "${PROJECT_ROOT}" -TOKEN_PYTHON = "${PYTHON}" -TOKEN_TOOLCHAIN_ROOT = "${TOOLCHAIN_ROOT}" - - -@dataclass(frozen=True) -class TokenValues: - """tan's already-resolved substitution values. Resolve each exactly ONCE - for the whole plan -- never re-resolved per-slice. - - `toolchain_root` is `None` (or blank) when this host has no toolchain - root resolved at all. Unresolved is NOT an error by itself: every plan - the SDK emits today names no `${TOOLCHAIN_ROOT}`, and those must keep - building on a host with no detectable toolchain install -- resolution is - lazy, the absence only becomes `UnresolvedToolchainRoot` when a plan - actually uses the token. Blank is folded into "unresolved" on purpose: - substituting `""` would turn `${TOOLCHAIN_ROOT}/bin/cmake` into the bare - `/bin/cmake` and sail past the leftover-token guard with nothing left to - catch -- the same hole `sdk_root` refuses. - """ - - sdk_root: str - project_root: str - python: str - toolchain_root: str | None - - -@dataclass(frozen=True) -class DemotedSlice: - """A slice whose fields still name `${TOOLCHAIN_ROOT}` with no host - value. Reported, not erred: the CALLER routes it to - `executionPolicy.missingTool` at dispatch -- this pass has no business - deciding skip vs fail, only noticing the condition and naming where it - hit.""" - - slice_index: int - core_id: str - field: str - - -class PlanTokenError(Exception): - """Why `substitute_plan_tokens` refused to hand back a plan.""" - - -class LeftoverToken(PlanTokenError): - """A `${...}`-shaped token survived substitution -- an unknown token (a - 5th token this CLI doesn't resolve), an unterminated `${` (truncation/ - typo), or a plan bug.""" - - def __init__(self, field: str, token: str) -> None: - super().__init__( - f"plan field `{field}` still contains an unresolved token `{token}` after substitution" - ) - self.field = field - self.token = token - - -class UnresolvedToolchainRoot(PlanTokenError): - """The plan names `${TOOLCHAIN_ROOT}` but this host has no toolchain root - resolved, in a field with no owning slice to demote to.""" - - def __init__(self, field: str) -> None: - super().__init__( - f"plan field `{field}` names `{TOKEN_TOOLCHAIN_ROOT}` but no toolchain root is " - f"resolved on this host" - ) - self.field = field - - -class UnknownPlanPathMode(PlanTokenError): - """`planPathMode` is present but isn't the one value this pass knows - (`"tokened"`).""" - - def __init__(self, mode: str) -> None: - super().__init__(f'unknown planPathMode `{mode}` (only "tokened" is defined)') - self.mode = mode - - -def sdk_commit_mismatches(plan_commit: str, resolved_commit: str) -> bool: - """The split-brain guard: whether a plan's `sdkCommit` (when present) - mismatches the resolved SDK checkout's actual HEAD. Compares by common- - length prefix so a short (`git rev-parse --short HEAD`) and a full - 40-char SHA both compare correctly; case-insensitive. Either side blank - -- an older plan without `sdkCommit`, or a caller that could not resolve - `git rev-parse` (no `.git`, `git` missing) -- is "no signal", never a - mismatch: an SDK checkout with no `.git` (a release tarball) is a - normal, supported setup.""" - a, b = plan_commit.strip(), resolved_commit.strip() - if not a or not b: - return False - n = min(len(a), len(b)) - return a[:n].lower() != b[:n].lower() - - -def _normalize(path: str) -> str: - """Lexically normalize (collapse `.`/`..`, drop empty segments) without - touching the filesystem -- the Python analogue of tan-core's - `path_guard::normalize`.""" - posix = path.replace("\\", "/") - is_absolute = posix.startswith("/") - parts = posix.split("/") - out: list[str] = [] - for part in parts: - if part in ("", "."): - continue - if part == "..": - # Matches Rust's `out.pop()`: a no-op on an empty accumulator, - # never appends a literal "..". - if out: - out.pop() - continue - out.append(part) - result = "/".join(out) - return f"/{result}" if is_absolute else result - - -def project_root_diverges_from_exec_base(project_root: str, exec_base: str) -> bool: - """Guard 3: whether `${PROJECT_ROOT}` (the resolved `board.yaml`'s - directory) diverges from the executor's actual base dir. They're the - same directory only in the default config -- when a plan is tokened, - substituting `${PROJECT_ROOT}` from one and executing slices under the - other would silently build against the wrong tree.""" - a, b = _normalize(project_root), _normalize(exec_base) - if sys.platform.startswith("win"): - # Lexical normalize doesn't fold drive-letter case, so - # `--board-yaml e:/...` vs `--project E:/...` -- the same path -- - # would otherwise false-fail this guard. - return a.lower() != b.lower() - return a != b - - -def _resolved_toolchain_root(values: TokenValues) -> str | None: - return values.toolchain_root if values.toolchain_root else None - - -def _apply(values: TokenValues, raw: str) -> str: - out = raw.replace(TOKEN_SDK_ROOT, values.sdk_root) - out = out.replace(TOKEN_PROJECT_ROOT, values.project_root) - out = out.replace(TOKEN_PYTHON, values.python) - root = _resolved_toolchain_root(values) - if root is not None: - out = out.replace(TOKEN_TOOLCHAIN_ROOT, root) - return out - - -def _find_brace_token_from(value: str, offset: int) -> tuple[int, str] | None: - """First `${...}`-shaped substring in `value` at or after `offset`, - together with its start offset. An unterminated `${` (no closing `}`) - still counts and consumes the rest of the string -- nothing after an - unterminated brace could itself be a well-formed further token.""" - start = value.find("${", offset) - if start == -1: - return None - rest = value[start:] - end = rest.find("}") - if end == -1: - return start, rest - return start, rest[: end + 1] - - -def _sub_field_lenient(field: str, raw: str, values: TokenValues) -> tuple[str, bool]: - """Substitute `values` into `raw`, then scan the WHOLE result for every - remaining `${...}`-shaped token -- not just the first: a field can carry - BOTH an unresolved `${TOOLCHAIN_ROOT}` and a genuinely unknown token, and - the unknown one must still fail loudly even when it comes second -- an - unknown token is a version/bug fact and outranks a provisioning fact. - Returns the substituted string plus whether an unresolved - `${TOOLCHAIN_ROOT}` was seen; the caller decides whether that's - plan-fatal (`_sub_field`) or demotable (`_substitute_slice` and its - helpers).""" - substituted = _apply(values, raw) - unresolved_toolchain = False - offset = 0 - while True: - found = _find_brace_token_from(substituted, offset) - if found is None: - break - start, token = found - if token != TOKEN_TOOLCHAIN_ROOT: - raise LeftoverToken(field, token) - # Reaching here means values.toolchain_root is unresolved: `_apply` - # already replaced every occurrence when a value WAS resolved. - unresolved_toolchain = True - offset = start + len(token) - return substituted, unresolved_toolchain - - -def _sub_field(field: str, raw: str, values: TokenValues) -> str: - """Plan-level sites (`boardYaml`, `sharedArtefacts[]`) have no owning - slice to route a missing-toolchain skip to, so they keep the hard, - byte-identical `UnresolvedToolchainRoot` this pass always raised.""" - substituted, unresolved_toolchain = _sub_field_lenient(field, raw, values) - if unresolved_toolchain: - raise UnresolvedToolchainRoot(field) - return substituted - - -def _record_first(field: str, unresolved: bool, current: str | None) -> str | None: - """Keep only the FIRST unresolved-toolchain field name; every field is - still substituted (and LeftoverToken-scanned) regardless, so a bug in a - LATER field of an already-demoted slice is never masked.""" - return field if unresolved and current is None else current - - -def _substitute_artefact(field: str, art: dict[str, Any], values: TokenValues) -> dict[str, Any]: - out = dict(art) - out["path"] = _sub_field(f"{field}.path", art["path"], values) - out["contents"] = _sub_field(f"{field}.contents", art["contents"], values) - return out - - -def _substitute_artefact_lenient( - field: str, art: dict[str, Any], values: TokenValues -) -> tuple[dict[str, Any], str | None]: - """The slice-owned variant of `_substitute_artefact`: `configArtefacts` - live inside a slice, so an unresolved `${TOOLCHAIN_ROOT}` in one is - demotable too (the whole artefact list is stripped from a demoted - slice's output plan by the caller) -- a `${UNKNOWN}`, though, is still - `LeftoverToken`, scanned for here BEFORE the caller ever gets a chance to - strip the list.""" - demoted_field: str | None = None - - path_field = f"{field}.path" - path_sub, path_unresolved = _sub_field_lenient(path_field, art["path"], values) - demoted_field = _record_first(path_field, path_unresolved, demoted_field) - - contents_field = f"{field}.contents" - contents_sub, contents_unresolved = _sub_field_lenient(contents_field, art["contents"], values) - demoted_field = _record_first(contents_field, contents_unresolved, demoted_field) - - out = dict(art) - out["path"] = path_sub - out["contents"] = contents_sub - return out, demoted_field - - -def _substitute_command_lenient( - i: int, cmd: SliceCommand, values: TokenValues -) -> tuple[SliceCommand, str | None]: - demoted_field: str | None = None - - new_cwd = cmd.cwd - if new_cwd is not None: - cwd_field = f"slices[{i}].command.cwd" - new_cwd, cwd_unresolved = _sub_field_lenient(cwd_field, new_cwd, values) - demoted_field = _record_first(cwd_field, cwd_unresolved, demoted_field) - - new_args: list[str] = [] - for j, arg in enumerate(cmd.args): - field = f"slices[{i}].command.args[{j}]" - sub, unresolved = _sub_field_lenient(field, arg, values) - new_args.append(sub) - demoted_field = _record_first(field, unresolved, demoted_field) - - return replace(cmd, cwd=new_cwd, args=new_args), demoted_field - - -def _substitute_slice(i: int, sl: Slice, values: TokenValues) -> tuple[Slice, str | None]: - """Substitute every field of one slice, leniently for - `${TOOLCHAIN_ROOT}`: returns the first field that still names it - unresolved, if any, so the caller can build a `DemotedSlice` -- but a - `${UNKNOWN}` anywhere in the slice still raises `LeftoverToken` - immediately, ending the scan right there. - - Field order mirrors `substitute_slice` in the Rust oracle exactly - (`buildDir`, then `configArtefacts`, then `env`, then `envAppendPath`, - then `command`), with `appDir` -- a field the Rust `BuildSlice` doesn't - carry -- inserted right after `buildDir`, the other bare slice-level path - field.""" - demoted_field: str | None = None - - build_dir_field = f"slices[{i}].buildDir" - build_dir, unresolved = _sub_field_lenient(build_dir_field, sl.build_dir, values) - demoted_field = _record_first(build_dir_field, unresolved, demoted_field) - - # appDir is nullable (a Yocto slice built from the stock-image token has - # none) -- guarded the same shape as command.cwd below, not substituted - # when absent. - app_dir = sl.app_dir - if app_dir is not None: - app_dir_field = f"slices[{i}].appDir" - app_dir, unresolved = _sub_field_lenient(app_dir_field, app_dir, values) - demoted_field = _record_first(app_dir_field, unresolved, demoted_field) - - new_artefacts: list[dict[str, Any]] = [] - for j, art in enumerate(sl.config_artefacts): - base = f"slices[{i}].configArtefacts[{j}]" - new_art, art_demoted = _substitute_artefact_lenient(base, art, values) - new_artefacts.append(new_art) - if art_demoted is not None: - demoted_field = _record_first(art_demoted, True, demoted_field) - - new_env: dict[str, str] = {} - for key, value in sorted(sl.env.items()): - field = f"slices[{i}].env.{key}" - sub, unresolved = _sub_field_lenient(field, value, values) - new_env[key] = sub - demoted_field = _record_first(field, unresolved, demoted_field) - - new_env_append: dict[str, list[str]] = {} - for key, values_list in sorted(sl.env_append_path.items()): - new_list: list[str] = [] - for k, value in enumerate(values_list): - field = f"slices[{i}].envAppendPath.{key}[{k}]" - sub, unresolved = _sub_field_lenient(field, value, values) - new_list.append(sub) - demoted_field = _record_first(field, unresolved, demoted_field) - new_env_append[key] = new_list - - new_command = sl.command - if new_command is not None: - new_command, cmd_demoted = _substitute_command_lenient(i, new_command, values) - if cmd_demoted is not None: - demoted_field = _record_first(cmd_demoted, True, demoted_field) - - new_slice = replace( - sl, - build_dir=build_dir, - app_dir=app_dir, - config_artefacts=new_artefacts, - env=new_env, - env_append_path=new_env_append, - command=new_command, - ) - return new_slice, demoted_field - - -def substitute_plan_tokens( - plan: BuildPlan, values: TokenValues -) -> tuple[BuildPlan, list[DemotedSlice]]: - """ONE blind string-substitution pass over every path-bearing string - field of `plan`, swapping the four literal tokens for `values`. A no-op - -- byte-identical, empty demotion list -- when `plan.plan_path_mode` is - absent. `UnknownPlanPathMode` when it's present but not exactly - `"tokened"`. - - After substitution, any leftover `${...}`-shaped token anywhere touched - fails the whole pass loudly -- EXCEPT a leftover `${TOOLCHAIN_ROOT}` - confined to a slice's own fields, which is reported via the returned - `DemotedSlice` list and has its `configArtefacts` stripped (nothing will - ever consume them this run), but the plan as a whole still succeeds. The - same token surviving in `boardYaml`/`sharedArtefacts[]` (no owning - slice) is still the hard `UnresolvedToolchainRoot` this pass always - raised. - - Ordering matches the Rust oracle: `boardYaml` first (hard site), then - every slice in order (each slice's own `configArtefacts` substituted - -- and stripped on demotion -- WITH it), then `sharedArtefacts` last - (hard site, cross-slice).""" - if plan.plan_path_mode is None: - # A fresh top-level object, matching Rust's `plan.clone()` -- returning - # `plan` itself would let a downstream mutation of the "output" plan - # silently alias back into the caller's input. - return replace(plan), [] - if plan.plan_path_mode != PLAN_PATH_MODE_TOKENED: - raise UnknownPlanPathMode(plan.plan_path_mode) - - # Hard site 1/2: no owning slice to demote to. - board_yaml = _sub_field("boardYaml", plan.board_yaml, values) - - demoted: list[DemotedSlice] = [] - new_slices: list[Slice] = [] - for i, sl in enumerate(plan.slices): - new_slice, demoted_field = _substitute_slice(i, sl, values) - if demoted_field is not None: - demoted.append(DemotedSlice(slice_index=i, core_id=sl.core_id, field=demoted_field)) - # Strip AFTER the slice's fields (including these artefacts' own - # contents) have been fully scanned -- a ${UNKNOWN} inside a - # demoted slice's configArtefact contents must still hard-fail - # as LeftoverToken before it is ever cleared here. - new_slice = replace(new_slice, config_artefacts=[]) - new_slices.append(new_slice) - - # Hard site 2/2: sharedArtefacts are cross-slice -- same "no owning - # slice" reasoning as boardYaml above. Substituted AFTER all slices. - new_shared = [ - _substitute_artefact(f"sharedArtefacts[{i}]", art, values) - for i, art in enumerate(plan.shared_artefacts) - ] - - return ( - replace(plan, board_yaml=board_yaml, slices=new_slices, shared_artefacts=new_shared), - demoted, - ) +# SPDX-License-Identifier: Apache-2.0 +"""Pure build-plan token substitution (alp-sdk #865, "hermetic build plans"). + +A **tokened** plan (`planPathMode: "tokened"`) carries literal placeholders -- +`${SDK_ROOT}` / `${PROJECT_ROOT}` / `${PYTHON}` / `${TOOLCHAIN_ROOT}` -- in its +path-bearing string fields instead of baking in the emitting machine's +absolute paths. This module is the CONSUMER side: ONE blind string- +substitution pass swapping the tokens for tan's already-resolved values, plus +the guards that keep a wrong substitution from silently building the wrong +image. + +Pure -- no IO. The caller (`tan.commands.build.token_substitution`) resolves +the SDK checkout root, the project root (the `board.yaml` directory), the +planner-venv Python and the toolchain root exactly ONCE and hands them in as +`TokenValues`; it also owns invoking `git` for the `sdkCommit` check +(`sdk_commit_mismatches` here only compares strings). + +A plan without `planPathMode: "tokened"` -- every plan the SDK emits today -- +is untouched by `substitute_plan_tokens`: a byte-identical no-op. + +tan-cli #89: an unresolved `${TOOLCHAIN_ROOT}` splits into two outcomes +depending on WHERE it survives substitution. `boardYaml` and +`sharedArtefacts[]` have no owning slice -- no dispatch seam to route a +skip/fail decision to -- so they keep the hard `UnresolvedToolchainRoot` this +pass always raised. A slice field, though, has an owning slice AND an owning +dispatch seam (`executionPolicy.missingTool`, the same knob a missing +`bitbake` already uses): this is a HOST-provisioning fact, not a plan/version +bug, so this pass reports it as a `DemotedSlice` instead of erroring the +whole plan, leaving the skip-vs-fail call to the caller at dispatch time. +`LeftoverToken` is unaffected either way -- an unknown token is a +version/bug fact, never demoted, always plan-fatal. + +Substituting an unresolved token with an empty string would sail past the +leftover-token guard and build against the wrong tree -- so every unresolved +token is REFUSED (or demoted, per the rule above), never degraded to "". + +Ported field-for-field from `crates/tan-core/src/plan_tokens.rs` +(`substitute_plan_tokens` / `substitute_slice` / `substitute_command_lenient` +/ `substitute_artefact` / `substitute_artefact_lenient`), with one field the +Rust `BuildSlice` does not carry: `slices[].appDir`, which the Python +`Slice` (`tan.core.build_plan`) does have. It is substituted immediately +after `buildDir` -- the other slice-level bare path field -- and, like every +other slice field, participates in TOOLCHAIN_ROOT demotion / LeftoverToken +the same as its siblings. `appDir` is nullable per the SDK schema (a Yocto +slice built from the stock-image token has none) -- `None` passes through +untouched, the same as `command.cwd`. +""" +import sys +from dataclasses import dataclass, replace +from typing import Any + +from tan.core.build_plan import BuildPlan, Slice, SliceCommand + +PLAN_PATH_MODE_TOKENED = "tokened" + +TOKEN_SDK_ROOT = "${SDK_ROOT}" +TOKEN_PROJECT_ROOT = "${PROJECT_ROOT}" +TOKEN_PYTHON = "${PYTHON}" +TOKEN_TOOLCHAIN_ROOT = "${TOOLCHAIN_ROOT}" + + +@dataclass(frozen=True) +class TokenValues: + """tan's already-resolved substitution values. Resolve each exactly ONCE + for the whole plan -- never re-resolved per-slice. + + `toolchain_root` is `None` (or blank) when this host has no toolchain + root resolved at all. Unresolved is NOT an error by itself: every plan + the SDK emits today names no `${TOOLCHAIN_ROOT}`, and those must keep + building on a host with no detectable toolchain install -- resolution is + lazy, the absence only becomes `UnresolvedToolchainRoot` when a plan + actually uses the token. Blank is folded into "unresolved" on purpose: + substituting `""` would turn `${TOOLCHAIN_ROOT}/bin/cmake` into the bare + `/bin/cmake` and sail past the leftover-token guard with nothing left to + catch -- the same hole `sdk_root` refuses. + """ + + sdk_root: str + project_root: str + python: str + toolchain_root: str | None + + +@dataclass(frozen=True) +class DemotedSlice: + """A slice whose fields still name `${TOOLCHAIN_ROOT}` with no host + value. Reported, not erred: the CALLER routes it to + `executionPolicy.missingTool` at dispatch -- this pass has no business + deciding skip vs fail, only noticing the condition and naming where it + hit.""" + + slice_index: int + core_id: str + field: str + + +class PlanTokenError(Exception): + """Why `substitute_plan_tokens` refused to hand back a plan.""" + + +class LeftoverToken(PlanTokenError): + """A `${...}`-shaped token survived substitution -- an unknown token (a + 5th token this CLI doesn't resolve), an unterminated `${` (truncation/ + typo), or a plan bug.""" + + def __init__(self, field: str, token: str) -> None: + super().__init__( + f"plan field `{field}` still contains an unresolved token `{token}` after substitution" + ) + self.field = field + self.token = token + + +class UnresolvedToolchainRoot(PlanTokenError): + """The plan names `${TOOLCHAIN_ROOT}` but this host has no toolchain root + resolved, in a field with no owning slice to demote to.""" + + def __init__(self, field: str) -> None: + super().__init__( + f"plan field `{field}` names `{TOKEN_TOOLCHAIN_ROOT}` but no toolchain root is " + f"resolved on this host" + ) + self.field = field + + +class UnknownPlanPathMode(PlanTokenError): + """`planPathMode` is present but isn't the one value this pass knows + (`"tokened"`).""" + + def __init__(self, mode: str) -> None: + super().__init__(f'unknown planPathMode `{mode}` (only "tokened" is defined)') + self.mode = mode + + +def sdk_commit_mismatches(plan_commit: str, resolved_commit: str) -> bool: + """The split-brain guard: whether a plan's `sdkCommit` (when present) + mismatches the resolved SDK checkout's actual HEAD. Compares by common- + length prefix so a short (`git rev-parse --short HEAD`) and a full + 40-char SHA both compare correctly; case-insensitive. Either side blank + -- an older plan without `sdkCommit`, or a caller that could not resolve + `git rev-parse` (no `.git`, `git` missing) -- is "no signal", never a + mismatch: an SDK checkout with no `.git` (a release tarball) is a + normal, supported setup.""" + a, b = plan_commit.strip(), resolved_commit.strip() + if not a or not b: + return False + n = min(len(a), len(b)) + return a[:n].lower() != b[:n].lower() + + +def _normalize(path: str) -> str: + """Lexically normalize (collapse `.`/`..`, drop empty segments) without + touching the filesystem -- the Python analogue of tan-core's + `path_guard::normalize`.""" + posix = path.replace("\\", "/") + is_absolute = posix.startswith("/") + parts = posix.split("/") + out: list[str] = [] + for part in parts: + if part in ("", "."): + continue + if part == "..": + # Matches Rust's `out.pop()`: a no-op on an empty accumulator, + # never appends a literal "..". + if out: + out.pop() + continue + out.append(part) + result = "/".join(out) + return f"/{result}" if is_absolute else result + + +def project_root_diverges_from_exec_base(project_root: str, exec_base: str) -> bool: + """Guard 3: whether `${PROJECT_ROOT}` (the resolved `board.yaml`'s + directory) diverges from the executor's actual base dir. They're the + same directory only in the default config -- when a plan is tokened, + substituting `${PROJECT_ROOT}` from one and executing slices under the + other would silently build against the wrong tree.""" + a, b = _normalize(project_root), _normalize(exec_base) + if sys.platform.startswith("win"): + # Lexical normalize doesn't fold drive-letter case, so + # `--board-yaml e:/...` vs `--project E:/...` -- the same path -- + # would otherwise false-fail this guard. + return a.lower() != b.lower() + return a != b + + +def _resolved_toolchain_root(values: TokenValues) -> str | None: + return values.toolchain_root if values.toolchain_root else None + + +def _apply(values: TokenValues, raw: str) -> str: + out = raw.replace(TOKEN_SDK_ROOT, values.sdk_root) + out = out.replace(TOKEN_PROJECT_ROOT, values.project_root) + out = out.replace(TOKEN_PYTHON, values.python) + root = _resolved_toolchain_root(values) + if root is not None: + out = out.replace(TOKEN_TOOLCHAIN_ROOT, root) + return out + + +def _find_brace_token_from(value: str, offset: int) -> tuple[int, str] | None: + """First `${...}`-shaped substring in `value` at or after `offset`, + together with its start offset. An unterminated `${` (no closing `}`) + still counts and consumes the rest of the string -- nothing after an + unterminated brace could itself be a well-formed further token.""" + start = value.find("${", offset) + if start == -1: + return None + rest = value[start:] + end = rest.find("}") + if end == -1: + return start, rest + return start, rest[: end + 1] + + +def _sub_field_lenient(field: str, raw: str, values: TokenValues) -> tuple[str, bool]: + """Substitute `values` into `raw`, then scan the WHOLE result for every + remaining `${...}`-shaped token -- not just the first: a field can carry + BOTH an unresolved `${TOOLCHAIN_ROOT}` and a genuinely unknown token, and + the unknown one must still fail loudly even when it comes second -- an + unknown token is a version/bug fact and outranks a provisioning fact. + Returns the substituted string plus whether an unresolved + `${TOOLCHAIN_ROOT}` was seen; the caller decides whether that's + plan-fatal (`_sub_field`) or demotable (`_substitute_slice` and its + helpers).""" + substituted = _apply(values, raw) + unresolved_toolchain = False + offset = 0 + while True: + found = _find_brace_token_from(substituted, offset) + if found is None: + break + start, token = found + if token != TOKEN_TOOLCHAIN_ROOT: + raise LeftoverToken(field, token) + # Reaching here means values.toolchain_root is unresolved: `_apply` + # already replaced every occurrence when a value WAS resolved. + unresolved_toolchain = True + offset = start + len(token) + return substituted, unresolved_toolchain + + +def _sub_field(field: str, raw: str, values: TokenValues) -> str: + """Plan-level sites (`boardYaml`, `sharedArtefacts[]`) have no owning + slice to route a missing-toolchain skip to, so they keep the hard, + byte-identical `UnresolvedToolchainRoot` this pass always raised.""" + substituted, unresolved_toolchain = _sub_field_lenient(field, raw, values) + if unresolved_toolchain: + raise UnresolvedToolchainRoot(field) + return substituted + + +def _record_first(field: str, unresolved: bool, current: str | None) -> str | None: + """Keep only the FIRST unresolved-toolchain field name; every field is + still substituted (and LeftoverToken-scanned) regardless, so a bug in a + LATER field of an already-demoted slice is never masked.""" + return field if unresolved and current is None else current + + +def _substitute_artefact(field: str, art: dict[str, Any], values: TokenValues) -> dict[str, Any]: + out = dict(art) + out["path"] = _sub_field(f"{field}.path", art["path"], values) + out["contents"] = _sub_field(f"{field}.contents", art["contents"], values) + return out + + +def _substitute_artefact_lenient( + field: str, art: dict[str, Any], values: TokenValues +) -> tuple[dict[str, Any], str | None]: + """The slice-owned variant of `_substitute_artefact`: `configArtefacts` + live inside a slice, so an unresolved `${TOOLCHAIN_ROOT}` in one is + demotable too (the whole artefact list is stripped from a demoted + slice's output plan by the caller) -- a `${UNKNOWN}`, though, is still + `LeftoverToken`, scanned for here BEFORE the caller ever gets a chance to + strip the list.""" + demoted_field: str | None = None + + path_field = f"{field}.path" + path_sub, path_unresolved = _sub_field_lenient(path_field, art["path"], values) + demoted_field = _record_first(path_field, path_unresolved, demoted_field) + + contents_field = f"{field}.contents" + contents_sub, contents_unresolved = _sub_field_lenient(contents_field, art["contents"], values) + demoted_field = _record_first(contents_field, contents_unresolved, demoted_field) + + out = dict(art) + out["path"] = path_sub + out["contents"] = contents_sub + return out, demoted_field + + +def _substitute_command_lenient( + i: int, cmd: SliceCommand, values: TokenValues +) -> tuple[SliceCommand, str | None]: + demoted_field: str | None = None + + new_cwd = cmd.cwd + if new_cwd is not None: + cwd_field = f"slices[{i}].command.cwd" + new_cwd, cwd_unresolved = _sub_field_lenient(cwd_field, new_cwd, values) + demoted_field = _record_first(cwd_field, cwd_unresolved, demoted_field) + + new_args: list[str] = [] + for j, arg in enumerate(cmd.args): + field = f"slices[{i}].command.args[{j}]" + sub, unresolved = _sub_field_lenient(field, arg, values) + new_args.append(sub) + demoted_field = _record_first(field, unresolved, demoted_field) + + return replace(cmd, cwd=new_cwd, args=new_args), demoted_field + + +def _substitute_slice(i: int, sl: Slice, values: TokenValues) -> tuple[Slice, str | None]: + """Substitute every field of one slice, leniently for + `${TOOLCHAIN_ROOT}`: returns the first field that still names it + unresolved, if any, so the caller can build a `DemotedSlice` -- but a + `${UNKNOWN}` anywhere in the slice still raises `LeftoverToken` + immediately, ending the scan right there. + + Field order mirrors `substitute_slice` in the Rust oracle exactly + (`buildDir`, then `configArtefacts`, then `env`, then `envAppendPath`, + then `command`), with `appDir` -- a field the Rust `BuildSlice` doesn't + carry -- inserted right after `buildDir`, the other bare slice-level path + field.""" + demoted_field: str | None = None + + build_dir_field = f"slices[{i}].buildDir" + build_dir, unresolved = _sub_field_lenient(build_dir_field, sl.build_dir, values) + demoted_field = _record_first(build_dir_field, unresolved, demoted_field) + + # appDir is nullable (a Yocto slice built from the stock-image token has + # none) -- guarded the same shape as command.cwd below, not substituted + # when absent. + app_dir = sl.app_dir + if app_dir is not None: + app_dir_field = f"slices[{i}].appDir" + app_dir, unresolved = _sub_field_lenient(app_dir_field, app_dir, values) + demoted_field = _record_first(app_dir_field, unresolved, demoted_field) + + new_artefacts: list[dict[str, Any]] = [] + for j, art in enumerate(sl.config_artefacts): + base = f"slices[{i}].configArtefacts[{j}]" + new_art, art_demoted = _substitute_artefact_lenient(base, art, values) + new_artefacts.append(new_art) + if art_demoted is not None: + demoted_field = _record_first(art_demoted, True, demoted_field) + + new_env: dict[str, str] = {} + for key, value in sorted(sl.env.items()): + field = f"slices[{i}].env.{key}" + sub, unresolved = _sub_field_lenient(field, value, values) + new_env[key] = sub + demoted_field = _record_first(field, unresolved, demoted_field) + + new_env_append: dict[str, list[str]] = {} + for key, values_list in sorted(sl.env_append_path.items()): + new_list: list[str] = [] + for k, value in enumerate(values_list): + field = f"slices[{i}].envAppendPath.{key}[{k}]" + sub, unresolved = _sub_field_lenient(field, value, values) + new_list.append(sub) + demoted_field = _record_first(field, unresolved, demoted_field) + new_env_append[key] = new_list + + new_command = sl.command + if new_command is not None: + new_command, cmd_demoted = _substitute_command_lenient(i, new_command, values) + if cmd_demoted is not None: + demoted_field = _record_first(cmd_demoted, True, demoted_field) + + new_slice = replace( + sl, + build_dir=build_dir, + app_dir=app_dir, + config_artefacts=new_artefacts, + env=new_env, + env_append_path=new_env_append, + command=new_command, + ) + return new_slice, demoted_field + + +def substitute_plan_tokens( + plan: BuildPlan, values: TokenValues +) -> tuple[BuildPlan, list[DemotedSlice]]: + """ONE blind string-substitution pass over every path-bearing string + field of `plan`, swapping the four literal tokens for `values`. A no-op + -- byte-identical, empty demotion list -- when `plan.plan_path_mode` is + absent. `UnknownPlanPathMode` when it's present but not exactly + `"tokened"`. + + After substitution, any leftover `${...}`-shaped token anywhere touched + fails the whole pass loudly -- EXCEPT a leftover `${TOOLCHAIN_ROOT}` + confined to a slice's own fields, which is reported via the returned + `DemotedSlice` list and has its `configArtefacts` stripped (nothing will + ever consume them this run), but the plan as a whole still succeeds. The + same token surviving in `boardYaml`/`sharedArtefacts[]` (no owning + slice) is still the hard `UnresolvedToolchainRoot` this pass always + raised. + + Ordering matches the Rust oracle: `boardYaml` first (hard site), then + every slice in order (each slice's own `configArtefacts` substituted + -- and stripped on demotion -- WITH it), then `sharedArtefacts` last + (hard site, cross-slice).""" + if plan.plan_path_mode is None: + # A fresh top-level object, matching Rust's `plan.clone()` -- returning + # `plan` itself would let a downstream mutation of the "output" plan + # silently alias back into the caller's input. + return replace(plan), [] + if plan.plan_path_mode != PLAN_PATH_MODE_TOKENED: + raise UnknownPlanPathMode(plan.plan_path_mode) + + # Hard site 1/2: no owning slice to demote to. + board_yaml = _sub_field("boardYaml", plan.board_yaml, values) + + demoted: list[DemotedSlice] = [] + new_slices: list[Slice] = [] + for i, sl in enumerate(plan.slices): + new_slice, demoted_field = _substitute_slice(i, sl, values) + if demoted_field is not None: + demoted.append(DemotedSlice(slice_index=i, core_id=sl.core_id, field=demoted_field)) + # Strip AFTER the slice's fields (including these artefacts' own + # contents) have been fully scanned -- a ${UNKNOWN} inside a + # demoted slice's configArtefact contents must still hard-fail + # as LeftoverToken before it is ever cleared here. + new_slice = replace(new_slice, config_artefacts=[]) + new_slices.append(new_slice) + + # Hard site 2/2: sharedArtefacts are cross-slice -- same "no owning + # slice" reasoning as boardYaml above. Substituted AFTER all slices. + new_shared = [ + _substitute_artefact(f"sharedArtefacts[{i}]", art, values) + for i, art in enumerate(plan.shared_artefacts) + ] + + return ( + replace(plan, board_yaml=board_yaml, slices=new_slices, shared_artefacts=new_shared), + demoted, + ) diff --git a/python/tan/core/renode_sim.py b/python/tan/core/renode_sim.py new file mode 100644 index 00000000..357c0239 --- /dev/null +++ b/python/tan/core/renode_sim.py @@ -0,0 +1,475 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Pure `tan renode --sim-mode` logic -- the studio hardware-simulator +contract. No IO: the `sim-descriptor.json` document, the generated sim boot +script, the headless sim argv, the control-socket line protocol (translate + +normalise + dispatch), and the Renode-monitor line classifier all live here as +IO-free functions. Sockets, the child process and the monitor plumbing are in +`tan.commands.renode_cmd`. + +Port of `crates/tan-core/src/renode/sim.rs`, unit-tested there -- that module's +own `#[cfg(test)]` block is the oracle for every case below (confirmed both by +reading it and by driving the shipped `tan.exe` oracle live through the full +`--sim-mode` pipeline: pre-flight gates, the generated `sim-descriptor.json` +and `.sim-boot.resc`, a real control-socket round trip, and both the +`renode.cpu-halted` and `renode.sim-exited-early` post-spawn outcomes). + +Despite `tan-cli#77`'s own framing ("no reference implementation, the retired +Python is gone"), a Rust port of exactly this contract already exists and is +built into the oracle -- `crates/tan-core/src/renode/sim.rs` + +`crates/tan-cli/src/commands/renode/{sim,monitor}.rs`, landed by +`5152fd4 feat(renode): implement the --sim-mode socket contract (#77) (#96)`. +This module is a faithful Python port of THAT (frozen, but readable and +already CI-verified) Rust code, not a fresh re-derivation from issue prose. + +The contract itself is NOT re-derived from prose: the Rust module says it was +ported from the retired Python `west alp-renode --sim-mode` +(`scripts/west_commands/alp_renode.py`, deleted in `alp-sdk@df312cec` under +ADR-0020 Phase 4) whose own opt-in e2e test pinned the wire behaviour. The +four wire elements that issue prose alone would omit and the Python carried -- +the `ERR ` reply, the `ready (timeout ...` readiness marker, the +LOWERCASE `0xnn` hex reply formatting, and the Secure `SCB->VTOR` write -- are +all reproduced here (the marker is emitted by the CLI, being IO). + +SCOPE (`tan-cli#77`, socket half): ports + descriptor + readiness marker + the +three-verb control protocol. DEFERRED to a follow-up on the same issue: the +`ram_console_buf` RAM-ring -> UART-socket streamer, the wired-UART console +path (Renode's own socket terminal), and the per-SKU `_SIM_BOARD_PROFILES` +that fill the descriptor's `framebuffers`/`peripherals` -- which are `[]` +here. The retired Python REFUSED a SKU with no profile; tan serves the socket +half instead, and says so out loud through [`sim_profile_deferred_message`] +-- never silently. + +Historical contract: `alplabai/alp-sdk#674` (CLOSED 2026-07-13). Never cite a +bare `#674` from this repo -- read here it means `tan-cli#674`, which has +never existed and 404s. Always carry the owning repo. (The Rust source this +was ported from makes the same point but never names its OWN repo either -- +`crates/tan-core/src/renode/mod.rs:14` says only "issue #674", which is +exactly the missing-prefix shape that let three alp-sdk workflows drift into +linking a nonexistent `tan-cli#674`. `crates/` is frozen and out of scope to +edit here; flagged instead of fixed.) +""" +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum +from typing import Any + +#: SKUs whose retired-Python `_SIM_BOARD_PROFILES` console was a WIRED +#: hardware UART (`{"kind": "uart", ...}`, served by Renode's own socket +#: terminal) rather than the `ram_console_buf` RAM ring. For these the UART +#: socket is silent here for a SECOND reason -- the wired-console path is +#: deferred too -- so the warning says so instead of letting an operator +#: assume the firmware simply printed nothing. +WIRED_CONSOLE_SKUS: tuple[str, ...] = ("E1M-AEN801",) + +#: The largest value `parse_int_auto` accepts -- mirrors Rust `u64::MAX`. A +#: token whose value doesn't fit is `None`, matching `u64::from_str_radix`'s +#: own failure rather than silently widening to Python's arbitrary-precision +#: ints. +_U64_MAX = (1 << 64) - 1 + + +class SimError(Exception): + """Why a control line's translation or dispatch failed. `str(err)` is + EXACTLY the reason text `dispatch_control_line` folds into its single + `ERR ` reply -- collapses the four Rust `SimError` variants + (`MalformedReadBytes`/`MalformedWriteBytes`/`WriteBytesNoData`/ + `ShortRead`) into one exception class, since nothing downstream branches + on which variant fired, only the rendered text.""" + + +def _rust_debug_str(text: str) -> str: + """Rust's `{:?}` for a `&str`: double-quoted, with `\\` and `"` escaped. + Duplicated (not imported) from `tan.commands.renode_cmd`'s identical + helper -- this module is pure/IO-free and must not depend on the command + file that depends on it.""" + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def _rust_debug_list(items: list[str]) -> str: + """Rust's `Vec` `{:?}` spelling -- double-quoted, comma-separated.""" + return "[" + ", ".join(_rust_debug_str(i) for i in items) + "]" + + +# ── sim-descriptor.json (studio SimDescriptorSchema) ──────────────────────── + + +def build_sim_descriptor(control_port: int, uart_port: int) -> dict[str, Any]: + """Assemble the `sim-descriptor.json` document -- studio's + `@alp/sim-protocol` `SimDescriptorSchema`. EXACTLY four keys, in this + order (a plain `dict` preserves insertion order, and `json.dumps` never + reorders it, mirroring `serde_json`'s `preserve_order`), and both socket + values are `tcp://127.0.0.1:` URIs. + + `framebuffers`/`peripherals` are empty: they come from the per-SKU sim + profiles, which are the deferred half of `tan-cli#77`. They are + present-and-empty rather than absent because the schema requires all + four keys -- a studio client that reads the descriptor must find the + arrays it iterates. Empty is NOT reported as success: every sim run + carries [`sim_profile_deferred_message`] as a warning issue. + """ + return { + "control_socket": f"tcp://127.0.0.1:{control_port}", + "uart_socket": f"tcp://127.0.0.1:{uart_port}", + "framebuffers": [], + "peripherals": [], + } + + +def sim_profile_deferred_message(sku: str) -> str: + """The warning every `--sim-mode` run carries while the per-SKU profile + half of `tan-cli#77` is deferred: it states plainly that the + descriptor's `framebuffers`/`peripherals` are empty and that the UART + socket is silent. + + This exists because the alternative is a descriptor that LOOKS + successful. The retired Python refused outright for any SKU with no + profile (`E1M-AEN801` and `E1M-V2N101` being the only two wired); tan + keeps exit 0 because the socket half genuinely works -- a client that + already knows a monitor node path can drive `sysbus ReadBytes` / + `WriteBytes` and any verbatim monitor line -- but a caller must not have + to infer the gap from an empty array. + """ + msg = ( + f"renode: no --sim-mode board profile for {sku} yet, so " + "sim-descriptor.json's `framebuffers` and `peripherals` are BOTH " + "empty — studio discovers no camera, display or sensor to " + "inject into — and the UART socket streams NOTHING (it accepts " + "and holds the connection, but the console stays silent). The " + "control socket works: `sysbus ReadBytes` / `sysbus WriteBytes` and " + "any verbatim monitor line are served, so a client that already " + "knows a node path can still drive the machine. The per-SKU " + "profiles are the deferred half of tan-cli#77." + ) + if sku in WIRED_CONSOLE_SKUS: + msg += ( + f" {sku}'s console was a WIRED hardware UART served by Renode's " + "own socket terminal, and that path is deferred as well — " + "a second, independent reason this run's UART socket is silent " + "rather than the firmware being quiet." + ) + return msg + + +def ready_marker(timeout: int) -> str: + """The readiness marker line. The substring `ready (timeout` is the + CONSUMER's poll token -- the retired Python's own e2e polled the process + output for it before reading `sim-descriptor.json`, so a reword strands + every consumer. It lives here, with the rest of the wire decisions, so + it is pinned by a test; `tan.commands.renode_cmd` only chooses which + stream to print it on.""" + return f"tan renode --sim-mode: ready (timeout {timeout}s)." + + +# ── generated boot script + argv ───────────────────────────────────────────── + + +def build_sim_resc_text(repl: str, elf: str, vtor: int | None) -> str: + """Generate the sim boot script: create the machine, load the platform, + load the ELF, seed the Secure VTOR, start. + + `vtor` (the image's vector-table address) is written to the Secure + `SCB->VTOR` (0xE000ED08) AFTER `LoadELF` and BEFORE `start`. On ARMv8-M + with TrustZone (Renode >= 1.16) `LoadELF` does NOT seed the Secure VTOR, + so every exception fetches its handler from address 0 and the core + HardFault-storms; on real silicon the boot ROM / secure world sets it. + Harmless on pre-TrustZone Renode, where 0xE000ED08 is just VTOR. `None` + writes nothing -- an image whose vector table could not be located + leaves Renode's own guess alone rather than being handed a wrong + address. + + `repl`/`elf` are plain path strings, not `pathlib.Path` -- matches the + rest of this command's manifest-consuming surface (`tan.core.renode_plan`), + which keeps a caller's own path style (native separators, unconverted) + rather than re-rendering it. + + This is a DIFFERENT mechanism from the plain smoke's `cpu + VectorTableOffset $vtor` monitor variable + (`tan.core.renode_plan.build_renode_argv`): the sim path owns its + generated script, so it writes the register directly and needs no + cooperation from an SDK-side `.resc`. The value is formatted like + Python's own `hex()` -- lowercase, unpadded -- which is what `f"{v:#x}"` + already does. + + The machine name is `v2n_sim` for every SKU: the retired Python's + `machine` parameter existed but `run_sim` never passed it, so this is + the only name the contract has ever used. + """ + vtor_line = f"sysbus WriteDoubleWord 0xE000ED08 {vtor:#x}\n" if vtor is not None else "" + return ( + 'mach create "v2n_sim"\n' + f"machine LoadPlatformDescription @{repl}\n" + f"sysbus LoadELF @{elf}\n" + f"{vtor_line}" + "start\n" + ) + + +def build_sim_renode_argv(renode_bin: str, resc: str) -> list[str]: + """Headless Renode argv for `--sim-mode`. `--console` keeps the monitor + on the child's stdin/stdout so the control bridge can drive it; the boot + script routes nothing else to stdout, so it carries only monitor + traffic. Flag order is a machine contract -- VERBATIM from the retired + Python.""" + return [renode_bin, "--disable-xwt", "--plain", "--console", "-e", f"i @{resc}"] + + +# ── control-line translation + ReadBytes normalisation ────────────────────── + + +def parse_int_auto(tok: str) -> int | None: + """Parse an integer token the way Python's own `int(tok, 0)` does: + `0x`/`0X` hex, `0b`/`0B` binary, `0o`/`0O` octal, else decimal. `None` on + anything else, including a value too large for a `u64` (mirrors + `u64::from_str_radix`'s own failure rather than silently widening to + Python's arbitrary-precision ints). + + DIVERGENCES from the retired Python, all in harmless directions and all + deliberate (mirrors the Rust port this function itself is ported from): + - a SIGNED token (`-1`) is REJECTED rather than accepted-and-masked. + Python's `int("-1", 0) & 0xFF` is 255 (infinite-precision two's + complement); a negative address or data byte is nonsense on this + wire, so it becomes an `ERR malformed ...` reply instead of a + silently-reinterpreted write. + - a LEADING-ZERO decimal (`010`) is ACCEPTED as 10, where + `int("010", 0)` raises (Python forbids it, to stop C programmers + reading it as octal -- octal needs the `0o` prefix). Accepting it + can only widen what a client may send. + - an UNDERSCORE-grouped token (`1_0`) is REJECTED, where + `int("1_0", 0)` is 10 (PEP 515 digit separators). Losing it can only + narrow what a client may send, and no studio client has ever + emitted one -- the wire carries `0x...` tokens machine-generated + from integers. + """ + if tok[:2] in ("0x", "0X"): + radix, digits = 16, tok[2:] + elif tok[:2] in ("0b", "0B"): + radix, digits = 2, tok[2:] + elif tok[:2] in ("0o", "0O"): + radix, digits = 8, tok[2:] + else: + radix, digits = 10, tok + if not digits or not digits.isascii() or not digits.isalnum(): + return None + try: + value = int(digits, radix) + except ValueError: + return None + return value if value <= _U64_MAX else None + + +def translate_control_command(line: str) -> tuple[int | None, list[str]]: + """Map one studio control line to `(read_count, renode_commands)` -- the + whole verb vocabulary, three arms: + + 1. `sysbus ReadBytes ` -> forwarded verbatim; `read_count` + is the requested byte count, and the reply needs byte-token + normalisation ([`normalize_readbytes_output`]). + 2. `sysbus WriteBytes ` -> EXPANDED to one `sysbus + WriteByte ` per byte, because Renode's own + `WriteBytes` takes `(bytes, addr)` -- the reverse of studio's + ` ` order. Byte and address are formatted + lowercase-unpadded, like Python's `hex()`. `read_count` is `None`. + 3. anything else (a peripheral `inject` template, a property get/set) + -> forwarded VERBATIM; `read_count` is `None` and the reply is its + first non-empty output line, or `ok`. + + The control socket is deliberately NOT Renode's raw telnet monitor: + studio's wire vocabulary does not match Renode's monitor API 1:1, so + the bridge translates and normalises to exactly one reply line. + + Raises [`SimError`] on a malformed base/count/data token, or a + `WriteBytes` with no data bytes. + """ + parts = line.split() + if len(parts) >= 4 and parts[0] == "sysbus" and parts[1] == "ReadBytes": + base_tok, count_tok = parts[2], parts[3] + if parse_int_auto(base_tok) is None: + raise SimError( + f"malformed ReadBytes {_rust_debug_str(line)}: invalid integer " + f"token {_rust_debug_str(base_tok)}" + ) + count = parse_int_auto(count_tok) + if count is None: + raise SimError( + f"malformed ReadBytes {_rust_debug_str(line)}: invalid integer " + f"token {_rust_debug_str(count_tok)}" + ) + # Forward the ORIGINAL token text, not a reformatted value: the + # retired Python did, and Renode is the one that parses it. + return count, [f"sysbus ReadBytes {base_tok} {count_tok}"] + + if len(parts) >= 3 and parts[0] == "sysbus" and parts[1] == "WriteBytes": + base = parse_int_auto(parts[2]) + if base is None: + raise SimError( + f"malformed WriteBytes {_rust_debug_str(line)}: invalid integer " + f"token {_rust_debug_str(parts[2])}" + ) + data: list[int] = [] + for tok in parts[3:]: + value = parse_int_auto(tok) + if value is None: + raise SimError( + f"malformed WriteBytes {_rust_debug_str(line)}: invalid " + f"integer token {_rust_debug_str(tok)}" + ) + # `& 0xFF` verbatim from the retired Python: an oversized token + # is masked, not rejected. + data.append(value & 0xFF) + if not data: + raise SimError(f"WriteBytes with no data bytes: {_rust_debug_str(line)}") + cmds: list[str] = [] + for i, byte in enumerate(data): + addr = base + i + if addr > _U64_MAX: + # NOT the "invalid integer token" phrasing: every token + # parsed fine here -- it is the arithmetic that failed. + raise SimError( + f"malformed WriteBytes {_rust_debug_str(line)}: base " + f"{base:#x} + byte offset {i} overflows a 64-bit address" + ) + cmds.append(f"sysbus WriteByte {addr:#x} {byte:#x}") + return None, cmds + + return None, [line] + + +def _hex_tokens_low_bytes(s: str) -> list[int]: + """Every `0[xX]` token in `s`, masked to its low byte. + + Masking is done by taking the token's LAST TWO hex digits rather than + parsing the whole token: that is exactly `value & 0xFF`, and it cannot + overflow on an arbitrarily long token the way a fixed-width parse + would. + """ + out: list[int] = [] + i, n = 0, len(s) + hexdigits = "0123456789abcdefABCDEF" + while i + 2 < n: + if s[i] == "0" and s[i + 1] in ("x", "X") and s[i + 2] in hexdigits: + start = i + 2 + j = start + while j < n and s[j] in hexdigits: + j += 1 + tail = s[max(j - 2, start) : j] + out.append(int(tail, 16)) + i = j + else: + i += 1 + return out + + +def normalize_readbytes_output(renode_out: str, count: int) -> str: + """Turn Renode `ReadBytes` output into `count` space-separated LOWERCASE + `0xnn` tokens on ONE line -- the studio control-socket reply contract. + Renode prints a bracketed, comma-separated, UPPER-case list spread over + lines (`[\\n0xDE, 0xAD, \\n]`); studio wants `0xde 0xad`. + + Only the bracketed body is scanned. Scoping to the brackets is what + keeps an echoed command line from leaking in as a phantom data byte -- + the echo of `sysbus ReadBytes 0x20000000 4` carries `0x20000000`, which + masks to `0x00`. + + Raises [`SimError`] when fewer than `count` byte tokens were seen: a + short read is a real error, never silently padded. + """ + lo, hi = renode_out.find("["), renode_out.rfind("]") + body = renode_out[lo + 1 : hi] if lo != -1 and hi != -1 and lo < hi else renode_out + byte_values = _hex_tokens_low_bytes(body) + if len(byte_values) < count: + raise SimError( + f"ReadBytes returned {len(byte_values)} bytes, expected {count}: " + f"{_rust_debug_str(renode_out)}" + ) + return " ".join(f"{b:#04x}" for b in byte_values[:count]) + + +# ── the full control-socket dispatch ───────────────────────────────────────── + + +def _single_line(s: str) -> str: + """Collapse CR/LF to spaces so a reply can never span lines.""" + return s.replace("\r", " ").replace("\n", " ").strip() + + +def dispatch_control_line(line: str, run: Callable[[str], str]) -> str: + """Run ONE studio control line through the bridge and return its single + reply line (no trailing newline). `run` executes one Renode monitor + command and returns its captured output, or raises with the failure + reason as its message. + + Never fails: a malformed line or a monitor error becomes `ERR ` + so the connection survives and one request -> one reply always holds. + The reply is flattened to a single line for the same reason -- a + multi-line reply would desynchronise a line-oriented client for the + rest of the session. + """ + try: + reply = _dispatch_inner(line.strip(), run) + except Exception as err: # noqa: BLE001 -- documented: this must never raise + reply = f"ERR {err}" + return _single_line(reply) + + +def _dispatch_inner(line: str, run: Callable[[str], str]) -> str: + count, cmds = translate_control_command(line) + if count is not None: + out = run(cmds[0]) + return normalize_readbytes_output(out, count) + out = "" + for cmd in cmds: + out = run(cmd) + # A property SET (an inject) prints nothing -> `ok`. A property GET + # prints its value -> echo the first non-empty line back so callers can + # read state. + for candidate in out.splitlines(): + candidate = candidate.strip() + if candidate: + return candidate + return "ok" + + +# ── Renode monitor line classification ─────────────────────────────────────── + + +class MonitorLine(Enum): + """What one Renode monitor stdout line means while awaiting a + sentinel.""" + + #: The bare sentinel -- this command's output is complete. + DONE = "done" + #: A monitor-side `[ERROR]`; must surface rather than be masked as `ok`. + ERROR = "error" + #: Noise to drop: the echoed sentinel-input, `[INFO]`/`[WARNING]` logs, + #: and the monitor's echo of the command we wrote. + IGNORE = "ignore" + #: Real command output. + OUTPUT = "output" + + +def classify_monitor_line(line: str, sentinel: str, cmd: str) -> MonitorLine: + """Classify one monitor line. Ordering is the contract and is + load-bearing: + + The monitor echoes each line we WRITE and then prints its output, so + the `echo ""` we append appears TWICE -- once as the echoed + input (`echo "__ALP_SIM_DONE_1__"`) and once as echo's own output (the + bare sentinel). Only the bare form, an EXACT match, terminates the + command; the echoed-input form is dropped so its token cannot pollute + the captured output. `[ERROR]` is checked BEFORE `[INFO]`/`[WARNING]`, + and is never dropped -- a monitor-side fault (a `WriteByte` to a + faulting address) must surface instead of being reported as `ok`. + """ + s = line.strip() + if s == sentinel: + return MonitorLine.DONE + if sentinel in s: + return MonitorLine.IGNORE + if "[ERROR]" in line: + return MonitorLine.ERROR + if "[INFO]" in line or "[WARNING]" in line: + return MonitorLine.IGNORE + if s == cmd or s.endswith(cmd): + return MonitorLine.IGNORE + return MonitorLine.OUTPUT diff --git a/python/tan/core/scaffold.py b/python/tan/core/scaffold.py index c619fc57..34f93442 100644 --- a/python/tan/core/scaffold.py +++ b/python/tan/core/scaffold.py @@ -58,11 +58,20 @@ ) #: The template a non-interactive `tan init` with no `--template` gets. -#: `zephyr-app`, NOT `minimal-app` (tan-cli #97): minimal-app's hand-generated -#: `CMakeLists.txt` never calls `find_package(Zephyr ...)`, so a bare `tan init` -#: followed by `tan build` used to point west at a plain host CMake project and -#: link an x86-64 binary for a core declared `os: zephyr`. Do not "simplify" -#: this back to the first registry entry. +#: `zephyr-app`, NOT `minimal-app` (tan-cli #97). Until tan-cli#309, TWO bugs +#: compounded: `board.yaml`'s `app: ./src` sent the planner's `_zephyr_app_dir` +#: straight at `src/CMakeLists.txt` (it has a `CMakeLists.txt` of its own, so +#: the parent-fallback that would have reached the real one never fired), and +#: THAT file called plain `add_executable(alp_app ...)` with no `find_package( +#: Zephyr ...)` at all -- so `west build -b /src` configured +#: and linked a genuine x86-64 host binary, silently, for a core declared +#: `os: zephyr`; the root `CMakeLists.txt` (dead code the whole time) was never +#: even the file at fault. tan-cli#309 fixed both: the generator itself +#: (`_minimal_app_root_cmake`/`_minimal_app_src_cmake` below) and `board.yaml`'s +#: `app:` (`_minimal_app_board_yaml`). zephyr-app stays the default regardless +#: -- that is a separate, still-live product choice (vendored from a real SDK +#: catalog entry vs. tan's own hand-generated stub), not something this fix +#: revisits. Do not "simplify" this back to the first registry entry. DEFAULT_TEMPLATE_ID = "zephyr-app" #: tan template id -> its vendored SDK scaffold-catalog directory. @@ -638,12 +647,54 @@ def _library_names(board_yaml: str) -> list[str]: # --------------------------------------------------------------------------- # # tan's OWN content, not a copy of anything the SDK ships -- the SDK catalog has -# no `minimal-app` entry (its `minimal` entry is what `zephyr-app` vendors). -# Deliberately a plain-CMake, non-west-buildable stub: it is the "I want full -# control over bring-up order" baseline, which is also why it is not the -# non-interactive default (see `DEFAULT_TEMPLATE_ID`). Ported from -# `wizard/service/c_project.rs`; `contract/envelopes/init-preview-minimal-app` -# pins its exact eight-file list. +# no `minimal-app` entry (its `minimal` entry is what `zephyr-app` vendors). Its +# `board.yaml` (below) declares `os: zephyr` and its README says so too -- it +# was always meant to build as a real Zephyr app; "hand-generated" describes +# where the content comes from (tan's own generator, not a vendored SDK +# capture), not a licence to skip Zephyr's own boilerplate. +# +# tan-cli#309 -- two bugs, not one, and fixing only the first makes the second +# worse (a CMake configure error instead of a silent host binary): +# +# 1. `board.yaml`'s `app:` decides which CMakeLists.txt `west build` actually +# configures, via the planner's `_zephyr_app_dir` +# (`tan/planner/orchestrator.py`): it resolves `app:` to a directory, and +# picks that directory ITSELF whenever it holds a `CMakeLists.txt` of its +# own, falling back to the PARENT only when it does not. This template's +# `src/` deliberately keeps its own `CMakeLists.txt` (the two-file split +# below), so `app: ./src` (through v0.5.0-rc3) sent `west build` straight at +# `src/CMakeLists.txt` -- the root `CMakeLists.txt` (`project()` + +# `add_subdirectory(src)`, never `add_executable`) was dead code the whole +# time, not the file at fault. +# 2. `src/CMakeLists.txt` -- the file actually configured -- called plain +# `add_executable(alp_app ${ALP_APP_SOURCES})`, no `find_package(Zephyr +# ...)` anywhere in either file. CMake configures and links that shape fine +# (measured: a real `alp_app.exe`, PE32+ x86-64, built from `CMakeFiles/ +# alp_app.dir/{main,features/app_bootstrap}.obj` -- app_bootstrap.c WAS +# compiled and linked, just into a host binary Zephyr's build never ran at +# all), so `tan build` reported success for a project that was never Zephyr. +# +# `_minimal_app_root_cmake`/`_minimal_app_src_cmake` below fix (2): the root +# file now carries `find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})` +# before `project()`, and `src/CMakeLists.txt` contributes to Zephyr's own +# `app` target via `target_sources(app ...)` instead of a second +# `add_executable` -- the same KIND of CMake every vendored template already +# writes (e.g. `templates/vendored/minimal/*/CMakeLists.txt`), while keeping +# its own hand-generated CONTENT. `_minimal_app_board_yaml` fixes (1): `app: .` +# (the project root) so `_zephyr_app_dir` resolves to the root file directly, +# without ever consulting `src/`. Measured after both fixes: a real CMake + +# Ninja + Zephyr-SDK configure+build compiles `src/features/app_bootstrap.c` +# into `app/libapp.a` alongside `src/main.c`, and Zephyr's own link step pulls +# `libapp.a` in whole (`-Wl,--whole-archive app/libapp.a`) on the way to a real +# `zephyr.elf`. +# +# Ported from `wizard/service/c_project.rs`/`gen_board_yaml`'s `app: ./src` up +# through both defects (`crates/` is FROZEN -- see `docs/ROADMAP.md`'s standing +# rule -- so tan-cli#309 is fixed here only, not there); `minimal-app` still +# is not the non-interactive default (see `DEFAULT_TEMPLATE_ID`), a separate, +# independent choice. `contract/envelopes/init-preview-minimal-app` pins its +# exact eight-file list/order (path + change-kind only, never file content or +# `board.yaml`'s `app:` value), which neither fix touches. #: minimal-app's one feature file: `(path, unit name, TODO line)`. _MINIMAL_APP_FEATURE_FILE = ( @@ -670,13 +721,7 @@ def _minimal_app_files(sku: str) -> list[PlannedFile]: PlannedFile("README.md", _minimal_app_readme(sku)), # No `prj_conf_extras`: minimal-app declares none. PlannedFile("prj.conf", "CONFIG_ASSERT=y\nCONFIG_NEWLIB_LIBC=y\n"), - PlannedFile( - "CMakeLists.txt", - "cmake_minimum_required(VERSION 3.20)\n" - "project(alp_starter C)\n" - "\n" - "add_subdirectory(src)\n", - ), + PlannedFile("CMakeLists.txt", _minimal_app_root_cmake()), PlannedFile("src/CMakeLists.txt", _minimal_app_src_cmake()), PlannedFile( "include/app/app.h", @@ -699,7 +744,21 @@ def _minimal_app_board_yaml(sku: str) -> str: """A board.yaml conforming to the SDK board schema: `som` + `cores` are the only required top-level keys, and the OS is per-core. There is deliberately NO top-level `os:` key (I-02) and no way to ask for one -- a core's runtime - follows its Cortex class, and this scaffold's app source is Zephyr.""" + follows its Cortex class, and this scaffold's app source is Zephyr. + + `app: .` (the PROJECT ROOT), not `./src` (tan-cli#309 round 2): + `_zephyr_app_dir` (`tan/planner/orchestrator.py`) resolves `app:` to the + directory holding the CMakeLists.txt `west build` actually configures, and + picks the `app:` path ITSELF whenever that path has its own + `CMakeLists.txt` -- falling back to its parent only when it does not. This + template's `src/` deliberately keeps a `CMakeLists.txt` of its own (the + two-file split `_minimal_app_root_cmake`/`_minimal_app_src_cmake` write), + so `app: ./src` sent `west build` straight at `src/CMakeLists.txt` -- + a bare `target_sources(app ...)` with no `find_package(Zephyr ...)` + of its own -- and skipped the root file (with the REAL `find_package`/ + `project()`) entirely. `app: .` resolves to the project root directly: + `_zephyr_app_dir` finds `CMakeLists.txt` right there and returns it + without ever consulting `src/`.""" return ( "# Generated by `tan init`.\n" "# board.yaml describes hardware: the SoM SKU + per-core app map.\n" @@ -710,7 +769,7 @@ def _minimal_app_board_yaml(sku: str) -> str: "cores:\n" f" {app_core_for_sku(sku)}:\n" " os: zephyr\n" - " app: ./src\n" + " app: .\n" ) @@ -738,17 +797,39 @@ def _minimal_app_readme(sku: str) -> str: ) +def _minimal_app_root_cmake() -> str: + """tan-cli#309: the file `board.yaml`'s `app: .` now points `west build` + at directly, so THIS is the file that has to carry Zephyr's boilerplate -- + before the fix it was `add_subdirectory(src)` with nothing before it, and + `board.yaml`'s `app: ./src` skipped straight past it to `src/CMakeLists.txt` + (`_minimal_app_board_yaml`'s docstring has the full mechanism). + `find_package(Zephyr ...)` has to run before `project()` -- Zephyr's own + convention (every vendored template does the same, e.g. + `templates/vendored/minimal/*/CMakeLists.txt`) -- because `find_package` + is what resolves the toolchain/board machinery `project()` consumes when it + enables the C language; reversing the order leaves `project()` running + before Zephyr's own CMake modules are even on `CMAKE_MODULE_PATH`.""" + return ( + "cmake_minimum_required(VERSION 3.20.0)\n" + "find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})\n" + "project(alp_starter C)\n" + "\n" + "add_subdirectory(src)\n" + ) + + def _minimal_app_src_cmake() -> str: + """Contributes to Zephyr's own `app` target via `target_sources`/ + `target_include_directories` -- never a second `add_executable`, which + Zephyr's build never links in (tan-cli#309).""" feature_path = _MINIMAL_APP_FEATURE_FILE[0] rel = feature_path[len("src/") :] if feature_path.startswith("src/") else feature_path return ( - "set(ALP_APP_SOURCES\n" + "target_sources(app PRIVATE\n" " main.c\n" f" {rel}\n" ")\n" - "\n" - "add_executable(alp_app ${ALP_APP_SOURCES})\n" - "target_include_directories(alp_app PRIVATE ../include)\n" + "target_include_directories(app PRIVATE ../include)\n" ) diff --git a/python/tan/core/setools.py b/python/tan/core/setools.py new file mode 100644 index 00000000..d9c92edb --- /dev/null +++ b/python/tan/core/setools.py @@ -0,0 +1,402 @@ +# SPDX-License-Identifier: Apache-2.0 +"""SETOOLS integration for the Flow D (`alif_mram_jlink`) slot0 sign step -- +tan-cli#353's remaining half. + +Both host paths that put a signed image into an Alif Ensemble part's MRAM +(`tan.core.flash_plan`'s Flow A/Flow D) need Alif's SETOOLS `app-gen-toc` step +to sign the ATOC first; alp-sdk's own manifest never carries a signed blob -- +measured on a fresh AEN801 emit, `flash_args` holds only +`jlink_flash_device`. Before this module, that meant a customer signed +OUTSIDE tan (alp-sdk's `docs/aen-provisioning.md` §3-4 -- that path is not in +THIS repo; alp-sdk is where it lives) and hand-edited the manifest +with the resulting `atoc`/`atoc_address` before `tan flash` would do anything +-- `flash_plan.plan_alif_mram_jlink`'s "both required" refusal names the +missing fields, not the vendor tool that produces them. + +**SETOOLS is license-gated and Alp Lab does not redistribute it** -- the same +stance `tan doctor`'s own `setools` check already takes. What this module +adds is: given a SETOOLS install the customer already has on disk, drive its +`app-gen-toc` step for them -- copy the build's raw `.bin`, write the JSON +config it wants, run it, and read back the ATOC placement it prints -- so +`tan flash` can complete end to end. RESOLVING that install is also this +module's job ([`resolve_setools_dir`]): an explicit `flash_args.setools_dir`, +then `SETOOLS_DIR`, in that order, and NOTHING ELSE -- no filesystem search -- +because a WRONG SETOOLS silently signing against the wrong part is worse than +tan refusing outright. + +**Not `tan.core.flash_plan`.** That module is pure/no-IO by its own +docstring; this one is not -- it copies a file, writes a config, and spawns +`app-gen-toc`, the same real-filesystem-work exception +`tan.core.venv`/`tan.core.bootstrap` already carry. Every DECISION about +*when* to call this module (never under `--dry-run`, never off the +`alif_mram_jlink` path, never once `atoc`/`atoc_address` are already +resolved) stays in `tan.commands.flash_cmd`, which is also the only caller. + +**No new hardware fact (ADR-0017 / I-26).** `mramAddress` is +`flash_args.slot0_load_address` verbatim -- already a documented Flow D key +(`flash_plan.plan_alif_mram_jlink`) -- and `cpu_id` is the manifest's own +`core_id` upper-cased (`m55_he` -> `M55_HE`); neither is invented here. The +written config also omits SETOOLS' own `"DEVICE"` key on purpose: +alp-sdk's `docs/aen-provisioning.md` §4 (not a path in THIS repo) is explicit +that "the on-module factory DEVICE config is already correct for your part, so +write an app-only ATOC (don't overwrite the device config)" -- inventing a +device profile here would be exactly the new hardware fact ADR-0017 forbids, +for no documented benefit. + +ponytail: `cpu_id = core_id.upper()` is a naming-convention bet, not a +metadata fact -- correct for every AEN `core_id` measured so far (`m55_he` / +`m55_hp`). Upgrade path if a future `core_id` spelling ever diverges from +SETOOLS' own `cpu_id` vocabulary: a `flash_args.setools_cpu_id` override, +added once a real manifest needs one -- not added speculatively here. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from dataclasses import dataclass +from typing import Any + +from tan.core.flash_plan import ( + FLOW_D_METHOD, + FlashPlanError, + fa_str, + parse_atoc_start_address, + validate_identifier, +) + +#: The one SETOOLS executable this module drives. Bare name, no extension -- +#: the Alif Security Toolkit bundle (`app-release-exec-linux-SE_FW_x.y.z`) is +#: a Linux tool; nothing here guesses a `.exe`/`.bat` variant, matching `tan +#: doctor`'s own `setools_check` (`tan/commands/doctor_cmd.py`). +APP_GEN_TOC = "app-gen-toc" + +#: Seconds `app-gen-toc` may run before it is killed -- a local sign step over +#: one small binary, generous mainly against a hung/misconfigured SETOOLS +#: install (e.g. waiting on an interactive prompt an app-only ATOC should +#: never need). +APP_GEN_TOC_TIMEOUT_S = 120.0 + +#: SETOOLS' own fixed output locations, always relative to `$SETOOLS_DIR` and +#: never configurable -- reading these back is not "searching the +#: filesystem": they are the ONE place `app-gen-toc` itself writes, per +#: alp-sdk's `docs/aen-provisioning.md` and every bench script under alp-sdk's +#: `scripts/bench/aen/` -- NEITHER path exists in this repo (tan-cli); both +#: are alp-sdk paths, cited here only as the authority for the fixed shape. +_ATOC_BLOB_REL = os.path.join("build", "AppTocPackage.bin") +_ATOC_MAP_REL = os.path.join("build", "app-package-map.txt") + + +@dataclass(frozen=True) +class SetoolsSource: + """A resolved `$SETOOLS_DIR`, plus WHERE it came from -- every refusal + downstream names `source`, so a customer juggling both an explicit + manifest value and a shell export knows which one tan actually read.""" + + path: str + source: str + + +def resolve_setools_dir( + flash_args: Any, env: dict[str, str], flag: str | None = None +) -> SetoolsSource | None: + """Most-explicit-first (tan-cli#368): the `--setools-dir` CLI flag, then + `$SETOOLS_DIR`, then `flash_args.setools_dir`. `None` when none of the + three is set. Never a filesystem search and never a guess -- a wrong + SETOOLS signing against the wrong part is worse than refusing. + + The flag outranks the environment, which outranks the manifest -- + DELIBERATELY the opposite of most `flash_args` accessors in this codebase + (which read the manifest as authoritative). `build/system-manifest.yaml` + is regenerated by every `tan build` and alp-sdk's own emit carries no + `setools_dir` key at all, so a hand-edit there is silently destroyed by + the customer's next build (#368) -- it is the LEAST durable of the three, + not the most, and is ranked accordingly. `SETOOLS_DIR` survives a build + but is shell/session-scoped; `--setools-dir` is the one source pinnable + per invocation regardless of either, so it wins outright. + """ + if flag: + return SetoolsSource(flag, "the --setools-dir flag") + from_env = env.get("SETOOLS_DIR") + if from_env: + return SetoolsSource(from_env, "the SETOOLS_DIR environment variable") + explicit = fa_str(flash_args, "setools_dir") + if explicit: + return SetoolsSource(explicit, "flash_args.setools_dir") + return None + + +def _app_gen_toc_candidates(setools_dir: str) -> list[str]: + """Every filename [`find_app_gen_toc`] tries, in order -- shared with + [`missing_tool_message`] (tan-cli#369) so the diagnosis names EXACTLY + what was checked, never a conclusion beyond it. Bare `APP_GEN_TOC` + everywhere; also `APP_GEN_TOC + ".exe"` on Windows, since a genuine + Windows SETOOLS install ships the executable with an extension and the + bare name alone is never found there.""" + candidates = [os.path.join(setools_dir, APP_GEN_TOC)] + if os.name == "nt": + candidates.append(os.path.join(setools_dir, APP_GEN_TOC + ".exe")) + return candidates + + +def find_app_gen_toc(setools_dir: str) -> str | None: + """The first of [`_app_gen_toc_candidates`] that exists inside + `setools_dir`, or `None`. Incapable of raising -- `setools_dir` is a + customer-supplied path (`--setools-dir`, an env var, or + `flash_args.setools_dir`) that may hold anything.""" + try: + return next( + (c for c in _app_gen_toc_candidates(setools_dir) if os.path.isfile(c)), None + ) + except (OSError, ValueError): + return None + + +def unresolved_message() -> str: + """The guidance for `resolve_setools_dir` answering `None` -- names EVERY + accepted source, in PRECEDENCE ORDER, flag first (tan-cli#368): the flag + is the one source visible in `tan flash --help` and pinnable per + invocation, so it leads; the manifest field is named last and flagged as + build-owned, since `tan build` silently overwrites a hand-edit there on + the customer's next build. Modeled on `sdk_cmd.NO_SDK_NEXT_STEPS`/ + `doctor_cmd.setools_check`'s own tone: remedy first, blame never.""" + return ( + f"{FLOW_D_METHOD}: an AEN801 slot0 image needs a SIGNED ATOC, which only " + f"Alif's SETOOLS `{APP_GEN_TOC}` step can produce. SETOOLS is license-gated " + "and alp-sdk does not redistribute it -- install it from Alif, then point " + "tan at it, most-specific first: --setools-dir on the command line, " + "SETOOLS_DIR= in the environment, or flash_args.setools_dir in the " + "manifest (lowest precedence, and OVERWRITTEN by the next `tan build` -- " + "prefer the flag or the environment variable for a durable setting)." + ) + + +def missing_tool_message(setools: SetoolsSource) -> str: + """The guidance when `setools.path` resolved (from `setools.source`) but + [`find_app_gen_toc`] found nothing there -- distinct from + [`unresolved_message`] because the customer already told tan where to + look; the problem is what tan found there, not that nothing was named. + + **tan-cli#369.** Used to assert a CONCLUSION ("this does not look like an + Alif Security Toolkit install") identically for a directory that does not + exist at all, a path pointed at the `app-gen-toc` BINARY itself instead + of its parent directory, and a genuine Windows install + (`find_app_gen_toc` did not try `app-gen-toc.exe` -- fixed alongside + this). Names only what was actually checked: every candidate filename + [`_app_gen_toc_candidates`] tries, and whether `setools.path` is even a + real directory -- never a verdict the check did not make. + """ + candidates = _app_gen_toc_candidates(setools.path) + tried = " or ".join(f"'{c}'" for c in candidates) + try: + is_dir = os.path.isdir(setools.path) + except (OSError, ValueError): + is_dir = False + if is_dir: + where = f"SETOOLS not found at {tried} -- the directory exists but holds none of them." + else: + where = ( + f"SETOOLS not found at {tried} -- '{setools.path}' is not a directory at " + f"all. If it names the {APP_GEN_TOC} binary itself, point tan at its " + "PARENT directory instead." + ) + return ( + f"{FLOW_D_METHOD}: SETOOLS_DIR resolved to '{setools.path}' (via " + f"{setools.source}), but {where} Check the path, or re-download SETOOLS from " + "Alif." + ) + + +def slot0_config(name: str, binary: str, mram_address: str, cpu_id: str) -> dict[str, Any]: + """The `app-gen-toc` JSON config for one app-only slot0 ATOC -- the exact + shape the AEN801 bench flow signs by hand today (measured, tan-cli#353). + No top-level `"DEVICE"` key -- see the module docstring.""" + return { + name: { + "binary": binary, + "version": "1.0.0", + "mramAddress": mram_address, + "cpu_id": cpu_id, + "flags": ["boot"], + "signed": True, + } + } + + +def read_atoc_address(setools_dir: str) -> str | None: + """The ATOC placement `app-gen-toc` just wrote, out of its own + `build/app-package-map.txt` report. Reuses `flash_plan + .parse_atoc_start_address`'s parse (byte-identical to every bench + script's own `awk .../app-package-map.txt | tail -1`) -- the read half + lives here, not there, since `flash_plan` stays no-IO. `None` when the + report is not there; a caller decides what that means.""" + map_path = os.path.join(setools_dir, _ATOC_MAP_REL) + try: + with open(map_path, encoding="utf-8", errors="replace", newline="") as fh: + text = fh.read() + except OSError: + return None + return parse_atoc_start_address(text) + + +def _tail(stdout: str, stderr: str) -> str: + """The last 4 non-empty lines of whichever stream carries something -- + mirrors `flash_cmd._capture_tail`'s shape (not imported: that helper + reads a `flash_cmd._Outcome`, a shape this module has no reason to + depend on).""" + text = stderr if stderr.strip() else stdout + lines = [line for line in text.splitlines() if line.strip()][-4:] + return " | ".join(lines) if lines else "no output" + + +def _map_stat(atoc_map_path: str) -> tuple[int, int] | None: + """`(st_mtime_ns, st_size)` for `atoc_map_path`, or `None` when it does not + exist (yet). The before/after snapshot [`sign_slot0`] compares to detect a + soft failure WITHOUT deleting the file -- see its own docstring + (tan-cli#373). Both fields, not either alone: an APPEND changes both, so + the pair survives a coarse-mtime filesystem landing on a same-size + coincidence, or vice versa.""" + try: + st = os.stat(atoc_map_path) + except OSError: + return None + return st.st_mtime_ns, st.st_size + + +def sign_slot0( + setools_dir: str, + app_gen_toc: str, + artefact_bin: str, + entry_id: str, + mram_address: str, +) -> tuple[str, str]: + """Run one `app-gen-toc` sign step inside `setools_dir`: copy + `artefact_bin` into `build/images/`, write + `build/config/-slot0.json`, spawn + `app_gen_toc -f build/config/-slot0.json` with + `cwd=setools_dir` (its config path is relative to it, matching the + bench's own `cd $SETOOLS_DIR && ./app-gen-toc -f build/config/...`), then + read back the ATOC placement. Returns `(atoc_blob_path, atoc_address)`. + + Raises `FlashPlanError` -- naming `app-gen-toc`'s own captured output + where there is any -- on: a filesystem failure preparing the inputs, a + spawn failure or timeout, a non-zero exit, a successful exit whose + `app-package-map.txt` was not updated (see tan-cli#373 below) or carries + no `'APP Package Start Address:'` line, or a successful exit that did not + actually produce the ATOC blob. SETOOLS' own diagnostic is the + authoritative one; this does not try to reproduce it, only to surface it. + + **tan-cli#365 (BLOCKER) / tan-cli#373 (BLOCKER regression in #365's own + fix).** `_ATOC_MAP_REL`/`_ATOC_BLOB_REL` are FIXED, SETOOLS-wide paths -- + not per-`entry_id` like the config/image above -- so a PREVIOUS sign (this + entry, another entry, a hand-run by the customer) may already have left a + well-formed report and blob sitting there. Presence/parses-fine after the + spawn proves nothing about THIS spawn unless a soft failure (app-gen-toc + exits 0 without actually writing, or dies after partially running) can be + told apart from a real one. + + #365's own fix told them apart by DELETING both files first -- correct for + `AppTocPackage.bin` (below), wrong for `app-package-map.txt`: + `flash_plan.parse_atoc_start_address`'s own docstring documents it as + **APPEND-mode**, citing the measured bench scripts -- the accumulated sign + record for the whole SETOOLS install, including hand-runs done outside + tan, not per-run scratch. Deleting it destroyed that history the moment + app-gen-toc recreated it holding only THIS run's block: a manifest with a + second Flow D entry pointing its own `flash_args.atoc_map` at this same + file would then read back THIS entry's address paired with THAT entry's + own blob -- a mismatched ATOC burned into on-die MRAM, recoverable only by + re-provisioning over SE-UART. #373 replaces the unlink with a snapshot + ([`_map_stat`]): an append changes both `mtime` and `size`, so an + UNCHANGED snapshot after a zero exit is the same soft-failure signal, + without deleting anything. + """ + validate_identifier(entry_id, "the flash target id") + setools_dir = os.path.abspath(setools_dir) + app_gen_toc = os.path.abspath(app_gen_toc) + images_dir = os.path.join(setools_dir, "build", "images") + config_dir = os.path.join(setools_dir, "build", "config") + binary_name = f"{entry_id}.bin" + config_name = f"{entry_id}-slot0.json" + atoc_map_path = os.path.join(setools_dir, _ATOC_MAP_REL) + atoc_blob_path = os.path.join(setools_dir, _ATOC_BLOB_REL) + try: + os.makedirs(images_dir, exist_ok=True) + os.makedirs(config_dir, exist_ok=True) + shutil.copyfile(artefact_bin, os.path.join(images_dir, binary_name)) + config_path = os.path.join(config_dir, config_name) + with open(config_path, "w", encoding="utf-8", newline="\n") as fh: + json.dump( + slot0_config(entry_id, binary_name, mram_address, entry_id.upper()), fh, indent=2 + ) + fh.write("\n") + # #373: NEVER deleted -- see the docstring above. Snapshotting (not + # removing) is what lets the post-spawn check below tell "app-gen-toc + # appended a fresh block" from "app-gen-toc touched nothing" without + # destroying whatever a prior run (this one's own, another entry's, or + # a hand-run) already left behind. + map_before = _map_stat(atoc_map_path) + # AppTocPackage.bin, unlike the map, is NOT append-mode: app-gen-toc + # (over)writes the one current blob whole every run, so there is no + # history in it to lose -- removing it beforehand is a safe + # presence-after-spawn check on THIS spawn, not a destructive one. + # (Two `tan flash` processes racing one SETOOLS_DIR could still both + # observe "present" here; not defended against, same as before.) + try: + os.remove(atoc_blob_path) + except FileNotFoundError: + pass + except OSError as err: + raise FlashPlanError( + f"{FLOW_D_METHOD}: could not prepare the SETOOLS sign step under " + f"'{setools_dir}': {err}" + ) from err + + config_rel = os.path.join("build", "config", config_name) + try: + proc = subprocess.run( + [app_gen_toc, "-f", config_rel], + cwd=setools_dir, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=APP_GEN_TOC_TIMEOUT_S, + ) + except subprocess.TimeoutExpired as err: + raise FlashPlanError( + f"{FLOW_D_METHOD}: {APP_GEN_TOC} timed out after " + f"{APP_GEN_TOC_TIMEOUT_S:.0f}s signing {config_rel}" + ) from err + except OSError as err: + raise FlashPlanError(f"{FLOW_D_METHOD}: could not run {app_gen_toc}: {err}") from err + if proc.returncode != 0: + raise FlashPlanError( + f"{FLOW_D_METHOD}: {APP_GEN_TOC} -f {config_rel} exited {proc.returncode}: " + f"{_tail(proc.stdout, proc.stderr)}" + ) + + # #373: the soft-failure guard's other half -- a PRE-EXISTING map whose + # snapshot did not move despite a zero exit was not appended to by THIS + # spawn, so trusting its last line would report an earlier run's address + # as this one's. Checked before parsing, so the message names the real + # problem instead of silently handing back a stale-but-well-formed value. + if map_before is not None and _map_stat(atoc_map_path) == map_before: + raise FlashPlanError( + f"{FLOW_D_METHOD}: {APP_GEN_TOC} exited 0 but {atoc_map_path} was not " + "updated (size and mtime unchanged) -- the sign step likely did not " + "actually run; check the SETOOLS config, or sign by hand." + ) + address = read_atoc_address(setools_dir) + if address is None: + raise FlashPlanError( + f"{FLOW_D_METHOD}: {APP_GEN_TOC} exited 0 but " + f"{atoc_map_path} carries no 'APP Package Start " + "Address:' line -- check the SETOOLS config, or sign by hand." + ) + if not os.path.isfile(atoc_blob_path): + raise FlashPlanError( + f"{FLOW_D_METHOD}: {APP_GEN_TOC} exited 0 and reported an address, but " + f"{atoc_blob_path} was not produced -- check the SETOOLS output." + ) + return atoc_blob_path, address diff --git a/python/tan/core/zephyr_env.py b/python/tan/core/zephyr_env.py new file mode 100644 index 00000000..be0c7683 --- /dev/null +++ b/python/tan/core/zephyr_env.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Consumer-mechanism env gap-filler for `tan build`'s native executor: the +`ZEPHYR_BASE` / `EXTRA_ZEPHYR_MODULES` keys the plan deliberately does NOT +carry, filled in only when the plan didn't pin them ("plan wins / CLI fills +gaps"). Port of `zephyr_env_overrides`, `crates/tan-cli/src/commands/build/ +execute/env.rs` -- confirmed against the compiled Rust unit tests in that +module (`cargo test -p alp-tan-cli --bin tan commands::build::execute::env::`, +all 5 passing) since the oracle binary's own `--plan-from` implies `--plan` +(v0.4.1 limitation, `build_cmd.py`'s own docstring) and so cannot be driven to +dispatch a synthetic plan end to end without a real `alp_orchestrate.py` +emission. + +tan-cli#308: `ZEPHYR_BASE` is per ADR-0020 never carried by the plan at all -- +it is pure consumer mechanism, always hand-derived from the west workspace +`tan` itself resolved -- so a stale ambient `$ZEPHYR_BASE` left over from a +`source zephyr-env.sh` (or an older `tan bootstrap` next-steps block, before +tan-cli#301) must not silently win for the spawned build child.""" +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from tan.core.plan_exec import apply_env_append + + +def zephyr_env_overrides( + zephyr_base: Path | None, + sdk_root: Path | None, + slice_env: dict[str, str], + env_append_path: dict[str, list[str]], + inherited: Callable[[str], str | None], +) -> list[tuple[str, str]]: + """Consumer-mechanism env the plan deliberately does NOT carry, filled in + as a gap-filler so `tan build` runs a plan slice with no manual setup: + + * `ZEPHYR_BASE` -- the resolved workspace's zephyr. Per ADR-0020 the plan + never emits this; it is pure consumer mechanism, always hand-derived. + * `EXTRA_ZEPHYR_MODULES` -- the alp-sdk checkout, so `west build -b + ` finds the SDK's boards. This now comes FROM the plan's + `env_append_path` for an SDK-emitted plan; the hand-derived value here + is only a FALLBACK for a plan that carries neither the slice-env pin + nor the `env_append_path` entry (plan wins / CLI fills gaps). + + Never overrides a key THIS slice's env pins. + + `inherited` is the parent-process env lookup (the same one the caller + already threads through to `assemble_slice_env` for `env_append_path` + seeding). The `EXTRA_ZEPHYR_MODULES` gap-filler must not just return the + bare SDK root: the caller's own gap-filler merge OVERWRITES rather than + appends (see `assemble_slice_env`'s docstring), so returning only the SDK + root here would silently replace a developer's own + `export EXTRA_ZEPHYR_MODULES=/my/module` with just the SDK root on any + plan that doesn't itself pin the key. Seed from `inherited` and append the + SDK root via the same `apply_env_append` (per-key separator, de-dup) the + plan-driven path uses, so the two paths agree on whether an inherited + value survives.""" + out: list[tuple[str, str]] = [] + if "ZEPHYR_BASE" not in slice_env and zephyr_base is not None: + out.append(("ZEPHYR_BASE", str(zephyr_base))) + + # Plan wins: skip the hand-derived value when the plan carries + # EXTRA_ZEPHYR_MODULES (as a slice-env pin or an env_append_path entry). + if "EXTRA_ZEPHYR_MODULES" not in slice_env and "EXTRA_ZEPHYR_MODULES" not in env_append_path: + if sdk_root is not None: + sdk = str(sdk_root) + base: list[tuple[str, str]] = [] + inherited_value = inherited("EXTRA_ZEPHYR_MODULES") + if inherited_value: + base.append(("EXTRA_ZEPHYR_MODULES", inherited_value)) + apply_env_append(base, {"EXTRA_ZEPHYR_MODULES": [sdk]}) + if base: + out.append(("EXTRA_ZEPHYR_MODULES", base[0][1])) + return out diff --git a/python/tan/envelope.py b/python/tan/envelope.py index 6e5577ef..f22e1968 100644 --- a/python/tan/envelope.py +++ b/python/tan/envelope.py @@ -113,7 +113,18 @@ def _serialise(self) -> tuple[str, int]: depends on that shape. """ try: - return json.dumps(self._as_dict(), separators=(",", ":")), self.exit_code + # `ensure_ascii=False`: the default (True) escapes every non-ASCII + # codepoint as `\uXXXX`, which is valid JSON but not what the + # oracle emits -- `serde_json::to_string` writes raw UTF-8 bytes + # verbatim (measured: `scaffold --name "Sensör Ölçüm"` on the + # Rust CLI puts the literal `Sensör Ölçüm` on the wire, not + # `Sensör...`). A consumer that byte-compares tan's envelope + # against the oracle's, or that greps stdout for a raw non-ASCII + # string, saw a divergence stdout never had a reason to carry. + return ( + json.dumps(self._as_dict(), separators=(",", ":"), ensure_ascii=False), + self.exit_code, + ) except Exception as err: # noqa: BLE001 -- no payload may ever crash stdout fallback_code = int(ExitCode.INTERNAL_FAILURE) fallback = { @@ -132,7 +143,10 @@ def _serialise(self) -> tuple[str, int]: f"failed to serialize command output: {err}", ).as_dict() ] - return json.dumps(fallback, separators=(",", ":")), fallback_code + return ( + json.dumps(fallback, separators=(",", ":"), ensure_ascii=False), + fallback_code, + ) #: Whether this process has already written its one envelope to stdout. diff --git a/python/tan/net.py b/python/tan/net.py index b4bff130..10306a3d 100644 --- a/python/tan/net.py +++ b/python/tan/net.py @@ -37,15 +37,37 @@ def default_ssl_context() -> ssl.SSLContext: """An `ssl.SSLContext` that actually has trust anchors, in a frozen build or not. Pass as `urllib.request.urlopen(..., context=default_ssl_context())`. """ + import certifi + try: import truststore - return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # tan-cli#354: the floor has to be UNDER truststore, not an + # ALTERNATIVE to it. `truststore.SSLContext(...)` constructs perfectly + # well on a host with an EMPTY OS trust store -- it defers to the + # platform verifier, and that verifier simply has no anchors. The + # failure then happens at VERIFY time, inside `urlopen`, which no + # `except` around construction can ever observe. So on any minimal + # container (`ubuntu:24.04`, `debian:*-slim`, and most CI base images + # ship no `ca-certificates`) every HTTPS call died + # `CERTIFICATE_VERIFY_FAILED` while tan's own `certifi` sat unused + # inside the freeze. Measured in a pristine `ubuntu:24.04`, and + # reproduced identically on the published `v0.5.0-rc4` asset -- so it + # predates the `--onedir` change and had shipped in every RC. + # + # Loading certifi into the SAME context WIDENS the anchor set instead + # of replacing it: a populated OS store -- the corporate-CA case #304 + # chose truststore for -- keeps working, and a host with no OS store + # can still verify public CAs. That is the "merge, never narrow" + # intent this module's docstring takes from + # `crates/tan-cli/src/http.rs`, which the original fall-back shape + # could not actually express. + context.load_verify_locations(cafile=certifi.where()) + return context except Exception: - # ImportError if truststore is somehow absent; anything else is - # truststore failing to reach the platform verifier (unsupported OS, - # a broken OS store). Either way, certifi's bundled list is a trust + # `ImportError` if truststore is absent; anything else is truststore + # failing outright (an unsupported OS, a store it cannot open) or + # refusing the extra anchors. Either way certifi alone is a trust # anchor set that does not depend on the platform at all. - import certifi - return ssl.create_default_context(cafile=certifi.where()) diff --git a/python/tan/templates/vendored/MANIFEST.md b/python/tan/templates/vendored/MANIFEST.md index bb0f4e1b..17b28ff9 100644 --- a/python/tan/templates/vendored/MANIFEST.md +++ b/python/tan/templates/vendored/MANIFEST.md @@ -191,23 +191,85 @@ supported SKU, before a single file is planned — never a silent fall-back onto a hand-written generator, which would keep alive on this one path the exact drift issue #14 retires everywhere else. -### `minimal-app` stays hand-generated (deferred, not permanent) +### `minimal-app` stays hand-generated; tan-cli#309 fixed its CMake AND its `app:` -**`minimal-app`** is semantically closest to SDK `minimal`, but its generator -emits a plain-CMake, non-west-buildable stub (`include/app/app.h` + -`src/CMakeLists.txt`), a structurally different shape than the SDK's -canonical Zephyr scaffold. It is the ONLY tan wizard template left -hand-generated after this vendoring pass — deliberately deferred, not a -permanent gap: folding it onto the same vendored tree as `zephyr-app` would -make the two templates byte-identical in the wizard's template picker (a -product decision to merge/deprecate one, not something to invent here), and -`contract/envelopes/init-preview-minimal-app/expected.json` pins its exact -file list (owned by an in-flight contract-surface change, so it stays -untouched until that lands). Because the stub is non-west-buildable, -`minimal-app` is **not** the non-interactive `tan init` default — -`zephyr-app` is (tan-cli #97). Do not restore it as the default without -vendoring it first: a `board.yaml` declaring `os: zephyr` over a plain-CMake -tree is exactly the silent host-binary build that issue reports. +**`minimal-app`** is semantically closest to SDK `minimal`, but it is not +vendored from the SDK catalog at all — it is tan's OWN generator +(`tan/core/scaffold.py`'s `_minimal_app_files`), the ONLY tan wizard template +left hand-generated after the vendoring pass above. Folding it onto the same +vendored tree as `zephyr-app` would make the two templates byte-identical in +the wizard's template picker (a product decision to merge/deprecate one, not +something to invent here), and `contract/envelopes/init-preview-minimal-app/ +expected.json` pins its exact eight-file list/order — path + change-kind +only, never file content or `board.yaml`'s `app:` value, so it did not need +re-pinning for the fix below. + +Through v0.5.0-rc3 this template had TWO compounding bugs, and an earlier pass +at this fix landed only the first — which, alone, turns a silent wrong-binary +build into a hard CMake configure error, not a working one (caught by +adversarial review before it shipped): + +1. **Which file `west build` even reads.** `board.yaml`'s `app:` decides this, + via the planner's `_zephyr_app_dir` (`tan/planner/orchestrator.py`): it + resolves `app:` to a directory and picks that directory ITSELF whenever it + holds a `CMakeLists.txt` of its own, falling back to the PARENT only when + it does not. This template's `src/` deliberately keeps its own + `CMakeLists.txt` (the two-file split below), so `app: ./src` sent `west + build` straight at `src/CMakeLists.txt` — the root `CMakeLists.txt` + (`project()` + `add_subdirectory(src)`, never `add_executable`) was dead + code the entire time, not the file at fault. +2. **What that file said.** `src/CMakeLists.txt` — the file actually + configured — called plain `add_executable(alp_app ${ALP_APP_SOURCES})`, no + `find_package(Zephyr ...)` anywhere in either CMake file. CMake configures + and links that shape fine: measured on a real checkout, `west build -b + /src` produced a genuine PE32+ x86-64 `alp_app.exe` built + from `CMakeFiles/alp_app.dir/{main,features/app_bootstrap}.obj` — + `app_bootstrap.c` WAS compiled and linked, just into a host binary Zephyr's + own build machinery never touched — so `tan build` reported success for a + project that was never Zephyr at all. + +tan-cli#309 fixed both, in `tan/core/scaffold.py`: `_minimal_app_root_cmake`/ +`_minimal_app_src_cmake` now emit `find_package(Zephyr REQUIRED HINTS +$ENV{ZEPHYR_BASE})` before `project()` in the root file, with `src/ +CMakeLists.txt` contributing via `target_sources(app PRIVATE ...)`/ +`target_include_directories(app ...)` against Zephyr's own `app` target +instead of a second `add_executable` — the same KIND of CMake every vendored +tree above already writes, while keeping its own smaller, hand-generated +CONTENT; and `_minimal_app_board_yaml` now emits `app: .` (the project root) +instead of `./src`, so `_zephyr_app_dir` resolves straight to the root file +without ever consulting `src/`. Measured after both fixes, against a real +CMake + Ninja + Zephyr SDK: configure reaches Zephyr's own boilerplate +(`Loading Zephyr default modules`, board/toolchain/devicetree resolution) and +a full build compiles `src/features/app_bootstrap.c` into `app/libapp.a` +alongside `src/main.c`, which Zephyr's own link step pulls in whole +(`-Wl,--whole-archive app/libapp.a`) on the way to a real `zephyr.elf`. + +**`crates/tan-core/src/wizard/service/c_project.rs` still emits the pre-#309 +broken shape (both bugs)** — `crates/` is frozen (`docs/ROADMAP.md`'s Standing +Rules) and is not re-fixed here; its own `wizard/vendored/MANIFEST.md` had +already flagged the CMake half of this ("a `board.yaml` declaring `os: +zephyr` over a plain-CMake tree is exactly the silent host-binary build that +issue [#14] reports") as "deliberately deferred, not a permanent gap" before +the freeze, and the freeze is why it never got un-deferred there. `minimal-app` +stays **not** the non-interactive `tan init` default — `zephyr-app` is +(tan-cli #97) — an independent choice (a vendored, real-catalog scaffold vs. +tan's own hand-generated stub) that #309 does not revisit. + +**A correction to tan-cli#309's own "Measured" evidence.** Its table reports +`Machine: ARM`, a real `zephyr.elf`, `CMakeFiles/app.dir/src/main.c.obj` +present, and no `app_bootstrap*.obj` — but under the pre-#309 shape (`app: +./src` + `src/CMakeLists.txt`'s `add_executable(alp_app ...)`), the object +directory is named `alp_app.dir` (the target's own name), never `app.dir`, +and paths are relative to `src/` (already the CMake source root), so the real +artefact is `alp_app.dir/main.obj`, not `app.dir/src/main.c.obj` — confirmed +by reproducing that exact shape locally. `app.dir/src/main.c.obj` and a real +ARM `zephyr.elf` are what the SAME `E1M-AEN801` plan's OTHER Zephyr slice +(`m55_he`, which builds `${SDK_ROOT}/firmware/alp-stock-shim`, a genuine +Zephyr app with its own unrelated `src/main.c`) would produce. The evidence +table most likely mixed that slice's build output into the `minimal-app` +customer slice's (`m55_hp`) row — the underlying defect (silent wrong-binary +build) is real and independently reproduced above, but that one table +conflates two slices. **`host-tooling-starter`** (a host-tool monorepo scaffold, not a firmware/board.yaml project — categorically out of the scaffold catalog's diff --git a/python/tests/commands/test_bootstrap_command.py b/python/tests/commands/test_bootstrap_command.py index a8cc777a..75d33501 100644 --- a/python/tests/commands/test_bootstrap_command.py +++ b/python/tests/commands/test_bootstrap_command.py @@ -1620,12 +1620,29 @@ def test_macos_reads_its_own_tool_list_and_falls_back_to_posix_without_one(): assert legacy.prerequisites(MACOS) == legacy.prerequisites(LINUX) -def test_the_posix_refusal_stays_one_line_with_two_spaces_before_install(): - """`bootstrap.sh`'s wording, byte-for-byte. It names the tools and nothing - else; the per-tool commands travel in the STRUCTURED half only.""" +def test_the_posix_refusal_keeps_the_oracle_line_and_adds_the_doctor_fix_remedy(): + """Was `..._stays_one_line_with_two_spaces_before_install`, which asserted + the refusal is exactly ONE line. tan-cli#355 deliberately makes it two, so + that assertion now encodes the wrong intent and is inverted here rather than + left to fail. + + What is NOT negotiable, and is still pinned byte-for-byte, is `bootstrap.sh`'s + own first line -- including the TWO spaces before "Install", which any reflow + would silently eat. The per-tool commands still travel in the STRUCTURED half + only; that half of the original constraint is unchanged. + + What is added is a second line naming `tan doctor --build --fix`. The old + wording predates tan having an installer at all; tan-cli#91 gave it one, and + a pristine `ubuntu:24.04` showed a first-time customer being handed four + package names with no route to them while that command sat one subcommand + away. Withholding a remedy tan HAS, to match an oracle that never had one, + is parity serving nobody.""" install = parse_bootstrap_manifest(REAL_MANIFEST).install_for_host(LINUX) refusal = posix_refusal(["cmake", "ninja"], install) - assert refusal.lines == ("Missing required tools: cmake ninja. Install them and re-run.",) + assert len(refusal.lines) == 2, refusal.lines + assert refusal.lines[0] == "Missing required tools: cmake ninja. Install them and re-run." + assert " Install them" in refusal.lines[0], "the oracle's double space was reflowed away" + assert "tan doctor --build --fix" in refusal.lines[1] assert [m.command for m in refusal.missing] == [ "sudo apt-get install -y cmake", "sudo apt-get install -y ninja-build" ] diff --git a/python/tests/commands/test_build_command.py b/python/tests/commands/test_build_command.py index 4ecdd12f..78094152 100644 --- a/python/tests/commands/test_build_command.py +++ b/python/tests/commands/test_build_command.py @@ -124,15 +124,30 @@ def two_slice_plan(probe_args): """Two slices: one that really runs (the artefact probe) and one carrying ``command: null`` plus its matching ``warnings[]`` entry -- I-11's shape. Slice order is `sorted(coreId)` as the SDK emits it (I-06); `aaa_probe` - sorts first, so the probe IS the first dispatch.""" + sorts first, so the probe IS the first dispatch. + + Backend is ``baremetal``, not ``zephyr``: this fixture is dispatch/policy/ + envelope scaffolding across the whole file, and the probe tool never + loads real Zephyr CMake boilerplate -- a `zephyr` backend here would trip + the tan-cli#309 guard and report the probe slice `failed` on every case + that expects it `ok`. `backend` is the ONLY field that does that -- the + guard branches on `sl.backend` alone (`build/execute.py`), and the `cwd` + it inspects comes from `sl.command.cwd`, not from `buildDir`. `buildDir` + and `toolchain.id` were matched to it purely so the fixture does not read + as three different backends at once; both are inert here (nothing reads + `toolchain`, and `build_dir` reaches only token substitution and the + post-build manifest). The ``-zephyr`` suffix kept in the `configArtefacts` path + strings below is a separate, cosmetic naming convention -- several cases + elsewhere in this file assert those exact path literals, so they are + left as-is; it carries no Zephyr meaning of its own.""" def slice_(core_id, command, artefacts): return { "coreId": core_id, - "backend": "zephyr", - "buildDir": f"build/{core_id}-zephyr", + "backend": "baremetal", + "buildDir": f"build/{core_id}", "appDir": None, "configArtefacts": artefacts, - "toolchain": {"id": "zephyr"}, + "toolchain": {"id": "baremetal"}, "artifacts": {"elf": None}, "debug": {"console": "rtt"}, "command": command, @@ -344,11 +359,11 @@ def test_a_wholly_skipped_build_refuses_not_reports_success(project): def test_a_partial_build_where_only_some_slices_are_skipped_still_reports_ok(project): - """A user deliberately building only the Zephyr side on a host with no - Yocto toolchain must NOT need a flag: at least one slice built, so this - stays `ok: true` -- but the skipped slice(s) still land in `issues[]`, - naming their missing tool, so a consumer reading only `issues[]` can - still tell "2 of 3" from "3 of 3".""" + """A build where one slice's tool is present and runs while another + names a tool missing from the host must NOT need a flag: at least one + slice built, so this stays `ok: true` -- but the skipped slice(s) still + land in `issues[]`, naming their missing tool, so a consumer reading + only `issues[]` can still tell "2 of 3" from "3 of 3".""" plan_doc = two_slice_plan(ALL_ARTEFACTS) # Swap the null-command second slice for a real one naming a tool that # cannot exist on any host, so it takes the missing-tool branch (not the @@ -910,6 +925,29 @@ def test_no_plan_and_no_sdk_is_a_coded_envelope_not_a_traceback(project): assert "Traceback" not in proc.stderr +def test_an_unresolvable_explicit_sdk_root_is_treated_as_no_sdk_at_all(project): + # tan-cli#257/#258: a bogus `--sdk-root` used to be carried straight + # through as `sdk.sourceTier: "sdkRootFlag"` (`resolve_sdk_root_ladder` + # reports an explicit flag UNVALIDATED, by design, for callers that only + # report the tier), reach `_emit_plan` as a non-None `sdk_root`, and get + # refused for the NEXT missing thing instead -- `no board.yaml found`, + # in a directory with no board.yaml either -- with an extra `sdk` key the + # oracle never emits on this path. Measured against the oracle + # (`target/debug/tan.exe build --sdk-root ./nowhere --format json`): it + # refuses with `build.plan-unavailable` "no alp-sdk checkout found", exit + # 1, and no `sdk` key at all. A flag that silently changes meaning (SDK + # problem read as a project problem) is worse than one that fails + # outright. + proc = run_tan("build", "--sdk-root", "./nowhere", "--format", "json", cwd=project) + env = envelope_of(proc) + assert proc.returncode == 1, env + assert [i["code"] for i in env["issues"]] == ["build.plan-unavailable"], env["issues"] + assert "no board.yaml" not in env["issues"][0]["message"] + assert "sdk" not in env + assert env["data"] is None + assert "Traceback" not in proc.stderr + + def test_build_resolves_the_sdk_tan_init_pinned_with_no_sdk_root_flag_and_no_env_var( project, monkeypatch ): diff --git a/python/tests/commands/test_build_manifest.py b/python/tests/commands/test_build_manifest.py index 058bc76f..dad6354a 100644 --- a/python/tests/commands/test_build_manifest.py +++ b/python/tests/commands/test_build_manifest.py @@ -24,6 +24,7 @@ resolve_zephyr_artefact, write_post_build_manifest, ) +from tan.commands import build_cmd from tan.commands.build_cmd import ( discover_sdk_root, resolve_sdk_root_ladder, @@ -115,7 +116,14 @@ def test_discover_sdk_root_finds_an_ancestor(tmp_path): assert discover_sdk_root(nested) == tmp_path -def test_discover_sdk_root_none_when_nothing_nearby(tmp_path): +def test_discover_sdk_root_none_when_nothing_nearby(tmp_path, monkeypatch): + """Pinned like `test_build_planner_python.py:74-84` pins + `find_workspace_venv`: `discover_sdk_root`'s last tier walks EVERY + ancestor of `workspace` looking for `scripts/alp_project.py`, all the way + to the filesystem root -- a developer machine with an alp-sdk checkout + anywhere above the OS temp dir would red this test for reasons unrelated + to the code under test.""" + monkeypatch.setattr(build_cmd, "_is_sdk_root", lambda _path: False) workspace = tmp_path / "myproj" workspace.mkdir() assert discover_sdk_root(workspace) is None diff --git a/python/tests/commands/test_build_manifest_zephyr_guard.py b/python/tests/commands/test_build_manifest_zephyr_guard.py new file mode 100644 index 00000000..f23806a2 --- /dev/null +++ b/python/tests/commands/test_build_manifest_zephyr_guard.py @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#309 (upstream tan-cli #97): `zephyr_boilerplate_loaded` -- port of +`crates/tan-cli/src/commands/build/execute/manifest.rs`'s +`zephyr_boilerplate_loaded`/`dir_shows_zephyr`, confirmed against that +module's own compiled test suite (`cargo test -p alp-tan-cli --bin tan +native_execute_`, all 14 passing, including the three guard-specific cases: +`native_execute_refuses_a_zephyr_slice_whose_configure_never_loaded_zephyr`, +`native_execute_accepts_a_zephyr_slice_evidenced_by_the_cmake_cache`, +`native_execute_accepts_a_sysbuild_slice_evidenced_one_level_down`).""" +from tan.commands.build.manifest import zephyr_boilerplate_loaded + + +def test_false_on_a_never_configured_dir(tmp_path): + assert not zephyr_boilerplate_loaded(tmp_path) + + +def test_false_on_a_plain_host_configure_with_no_zephyr_evidence(tmp_path): + """The tan-cli #97 defect itself: a `CMakeCache.txt` exists (the tool + DID configure something) but carries no `ZEPHYR_BASE:` line and there is + no `zephyr/` output -- a plain host project, not firmware.""" + build = tmp_path / "build" + build.mkdir() + (build / "CMakeCache.txt").write_text( + "CMAKE_PROJECT_NAME:STATIC=alp_app\nCMAKE_GENERATOR:INTERNAL=Ninja\n", + encoding="utf-8", + ) + assert not zephyr_boilerplate_loaded(tmp_path) + + +def test_true_when_the_cmake_cache_carries_zephyr_base(tmp_path): + """The primary signal -- what `find_package(Zephyr)` actually caches. A + verified real Zephyr slice carries `ZEPHYR_BASE:PATH=...` and has NO + `zephyr/` directory, so this must be sufficient on its own.""" + build = tmp_path / "build" + build.mkdir() + (build / "CMakeCache.txt").write_text( + "CMAKE_PROJECT_NAME:STATIC=zephyr\nZEPHYR_BASE:PATH=/work/zephyr\n", + encoding="utf-8", + ) + assert not (build / "zephyr").exists(), "cache-only evidence, the real-world case" + assert zephyr_boilerplate_loaded(tmp_path) + + +def test_true_when_a_zephyr_output_directory_exists_with_no_cache(tmp_path): + """The OR fallback signal -- kept narrow (never promoted to primary) so + the guard can only ever fail a build it is SURE about.""" + build = tmp_path / "build" + (build / "zephyr").mkdir(parents=True) + assert zephyr_boilerplate_loaded(tmp_path) + + +def test_true_for_a_sysbuild_slice_evidenced_one_level_down(tmp_path): + """`--sysbuild` nests the real per-image Zephyr build one directory + deeper than its own superbuild top level, which carries neither signal + (`share/sysbuild/CMakeLists.txt` calls `find_package(Sysbuild ...)`, not + `find_package(Zephyr)`). Without the one-level-down look this fails a + correct V2N sysbuild build.""" + build = tmp_path / "build" + nested_image = build / "alp_app" + (nested_image / "zephyr").mkdir(parents=True) + assert not (build / "zephyr").exists() + assert not (build / "CMakeCache.txt").exists() + assert zephyr_boilerplate_loaded(tmp_path) + + +def test_false_when_the_one_level_down_look_finds_nothing_either(tmp_path): + build = tmp_path / "build" + (build / "some_other_dir").mkdir(parents=True) + (build / "some_other_dir" / "unrelated.txt").write_text("x", encoding="utf-8") + assert not zephyr_boilerplate_loaded(tmp_path) + + +def test_non_utf8_cmake_cache_falls_back_to_the_zephyr_dir_signal(tmp_path): + """Review finding (blocking): a non-UTF-8 `CMakeCache.txt` (a stray + `CMAKE_C_COMPILER:FILEPATH=/opt/caf\\xe9/gcc`-style byte is real-world -- + some toolchain paths embed Latin-1 bytes) must not raise. The Rust + oracle's `std::fs::read_to_string(...).is_ok_and(...)` folds invalid UTF-8 + into the same `io::Error` a missing file gets and falls straight through + to the `zephyr/` fallback -- it never propagates. `UnicodeDecodeError` is + a `ValueError`, not an `OSError`; `_dir_shows_zephyr`'s `except` clause + must catch both or this raises out of `execute_slices`, whose own module + docstring promises no escaping exception.""" + build = tmp_path / "build" + build.mkdir() + (build / "CMakeCache.txt").write_bytes(b"CMAKE_C_COMPILER:FILEPATH=/opt/caf\xe9/gcc\n") + (build / "zephyr").mkdir() + assert zephyr_boilerplate_loaded(tmp_path) + + +def test_non_utf8_cmake_cache_with_no_zephyr_dir_is_false_not_a_raise(tmp_path): + """Same corrupt cache, but with no `zephyr/` fallback evidence either -- + must resolve to `False` (an unproven Zephyr build), never raise.""" + build = tmp_path / "build" + build.mkdir() + (build / "CMakeCache.txt").write_bytes(b"CMAKE_C_COMPILER:FILEPATH=/opt/caf\xe9/gcc\n") + assert not zephyr_boilerplate_loaded(tmp_path) diff --git a/python/tests/commands/test_build_streaming.py b/python/tests/commands/test_build_streaming.py index 77879edd..2ec52d08 100644 --- a/python/tests/commands/test_build_streaming.py +++ b/python/tests/commands/test_build_streaming.py @@ -102,9 +102,23 @@ def test_enabled_heartbeat_ticks_after_silence_then_clears_on_output(monkeypatch """Armed (TTY, text mode): silent for longer than the threshold -> prints a `still building` line; a real line arriving afterwards blanks it (a `\\r`-clear) before relaying, so it never mixes into the stream it - was standing in for.""" + was standing in for. + + `shutil.get_terminal_size` is pinned wide (see + `test_heartbeat_line_never_wraps_on_a_narrow_terminal` for the narrow + case this test does NOT cover): unpinned, it reads the REAL terminal -- + `COLUMNS`, on a host/CI runner that exports one, or the actual tty width + otherwise -- and a value narrower than the ~110-char message below would + truncate `"still building"` itself out of the line before this test's own + content assertion ever runs, failing for a reason unrelated to the code + under test.""" monkeypatch.setattr(build_cmd, "_HEARTBEAT_SILENCE_THRESHOLD_S", 0.05) monkeypatch.setattr(build_cmd, "_HEARTBEAT_TICK_S", 0.02) + monkeypatch.setattr( + build_cmd.shutil, + "get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((120, 24)), + ) fake_stderr = io.StringIO() monkeypatch.setattr(sys, "stderr", fake_stderr) @@ -167,7 +181,11 @@ def test_json_format_stdout_carries_no_heartbeat_bytes(project): and, spelled out for this ticket, none of the heartbeat's own vocabulary or control bytes -- proving `on_output=_Heartbeat(...)` never reaches stdout regardless of what the (disabled, non-TTY-here) heartbeat would - have printed on a real terminal.""" + have printed on a real terminal. `two_slice_plan` is already `baremetal` + (tan-cli#309: a `zephyr` backend here would trip the Zephyr-boilerplate + guard, since the probe command never produces real Zephyr CMake evidence), + which is all this test needs -- it asserts stdout framing only, and + Zephyr-ness is incidental to that.""" plan = write_plan(project, two_slice_plan(ALL_ARTEFACTS)) proc = run_tan( "build", "--plan-from", str(plan), "--execute", "--format", "json", cwd=project @@ -180,5 +198,12 @@ def test_json_format_stdout_carries_no_heartbeat_bytes(project): # `Envelope.to_json` is compact (`separators=(",", ":")`, no indent) plus # the one trailing newline `print()` adds -- exactly one line, byte for # byte, nothing appended or interleaved around it. + # + # `ensure_ascii=False` mirrors `Envelope.to_json`: the oracle emits raw + # UTF-8 (an em dash goes out as `e2 80 94`), so the port stopped escaping + # it to `—`. This expectation has to be built the way production + # builds it or the test measures `json.dumps`'s DEFAULT rather than what + # tan actually wrote -- which is what it was doing, and why it reddened on + # a message carrying an em dash rather than on any framing change. assert proc.stdout.count("\n") == 1 - assert proc.stdout == json.dumps(env, separators=(",", ":")) + "\n" + assert proc.stdout == json.dumps(env, separators=(",", ":"), ensure_ascii=False) + "\n" diff --git a/python/tests/commands/test_build_token_substitution.py b/python/tests/commands/test_build_token_substitution.py index 07ddb719..ecb651aa 100644 --- a/python/tests/commands/test_build_token_substitution.py +++ b/python/tests/commands/test_build_token_substitution.py @@ -1,300 +1,300 @@ -# SPDX-License-Identifier: Apache-2.0 -import shutil -import subprocess - -import pytest - -from tan.commands.build.token_substitution import ( - TokenSubstitutionError, - apply_plan_token_substitution, -) -from tan.core.build_plan import parse_build_plan - -LEGACY_PLAN = """{ - "schemaVersion": 1, "generatedBy": "g", "boardYaml": "/work/proj/board.yaml", "sku": "S", "buildRoot": "build", - "slices": [], "sharedArtefacts": [], "warnings": [] -}""" - - -@pytest.fixture -def sdk_root(tmp_path): - """A bare directory standing in for a resolved alp-sdk checkout -- this - layer takes `sdk_root` pre-resolved (unlike tan-cli's real resolver), so - the fixture only needs to exist on disk for `git -C ` to be - meaningful.""" - d = tmp_path / "sdk" - d.mkdir() - return d - - -def test_legacy_plan_is_untouched_no_op(): - plan = parse_build_plan(LEGACY_PLAN) - out, demoted = apply_plan_token_substitution( - plan, - board_yaml_path="/work/proj/board.yaml", - exec_base="/work/proj", - sdk_root="/opt/alp-sdk", - python="python3", - toolchain_root=None, - ) - assert out == plan - assert demoted == [] - - -def test_unknown_plan_path_mode_is_refused_before_any_guard_runs(): - """A board.yaml/exec_base pair that would ALSO fail the divergence guard - -- proving the unknown-mode check short-circuits first.""" - json = """{ - "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened-v2", - "boardYaml": "/work/proj/examples/foo/board.yaml", "sku": "S", "buildRoot": "build", - "slices": [], "sharedArtefacts": [], "warnings": [] - }""" - plan = parse_build_plan(json) - with pytest.raises(TokenSubstitutionError) as e: - apply_plan_token_substitution( - plan, - board_yaml_path="/work/proj/examples/foo/board.yaml", - exec_base="/work/proj", - sdk_root="/opt/alp-sdk", - python="python3", - toolchain_root=None, - ) - assert e.value.code == "build.plan-invalid" - assert "tokened-v2" in e.value.message - - -def test_missing_board_yaml_path_is_refused(): - json = """{ - "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", - "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", - "slices": [], "sharedArtefacts": [], "warnings": [] - }""" - plan = parse_build_plan(json) - with pytest.raises(TokenSubstitutionError) as e: - apply_plan_token_substitution( - plan, - board_yaml_path=None, - exec_base="/work/proj", - sdk_root="/opt/alp-sdk", - python="python3", - toolchain_root=None, - ) - assert e.value.code == "build.plan-invalid" - - -def test_project_root_mismatch_is_refused(): - json = """{ - "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", - "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", - "slices": [], "sharedArtefacts": [], "warnings": [] - }""" - plan = parse_build_plan(json) - # board.yaml lives nested under the workspace root, but the exec base - # stays the workspace root itself -- a real PROJECT_ROOT/exec-base split. - with pytest.raises(TokenSubstitutionError) as e: - apply_plan_token_substitution( - plan, - board_yaml_path="/work/proj/examples/foo/board.yaml", - exec_base="/work/proj", - sdk_root="/opt/alp-sdk", - python="python3", - toolchain_root=None, - ) - assert e.value.code == "build.project-root-mismatch" - assert "examples/foo" in e.value.message - - -def test_unresolved_sdk_root_is_refused_not_substituted_empty(): - """Regression: a tokened plan with no resolvable sdk_root must not - degrade ${SDK_ROOT} to "" -- turning ${SDK_ROOT}/scripts into the bare - /scripts sails right past the leftover-token guard.""" - json = """{ - "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", - "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", - "slices": [ - { "coreId": "c1", "backend": "zephyr", "buildDir": "build/c1", "appDir": "app", - "configArtefacts": [], "toolchain": null, "artifacts": {}, "debug": {}, - "command": { "tool": "west", "args": ["build"], "cwd": "build/c1" }, - "env": { "ALP_SDK_ROOT": "${SDK_ROOT}/scripts" }, "envAppendPath": {} } - ], - "sharedArtefacts": [], "warnings": [] - }""" - plan = parse_build_plan(json) - with pytest.raises(TokenSubstitutionError) as e: - apply_plan_token_substitution( - plan, - board_yaml_path="/work/proj/board.yaml", - exec_base="/work/proj", - sdk_root=None, - python="python3", - toolchain_root=None, - ) - assert e.value.code == "build.sdk-root-unresolved" - - -def test_tokened_plan_with_matching_project_root_substitutes(sdk_root): - json = """{ - "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", - "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", - "slices": [ - { "coreId": "c1", "backend": "zephyr", "buildDir": "build/c1", "appDir": "app", - "configArtefacts": [], "toolchain": null, "artifacts": {}, "debug": {}, - "command": { "tool": "west", "args": ["build"], "cwd": "build/c1" }, - "env": { "ALP_SDK_ROOT": "${SDK_ROOT}" }, "envAppendPath": {} } - ], - "sharedArtefacts": [], "warnings": [] - }""" - plan = parse_build_plan(json) - out, demoted = apply_plan_token_substitution( - plan, - board_yaml_path="/work/proj/board.yaml", - exec_base="/work/proj", - sdk_root=str(sdk_root), - python="python3", - toolchain_root=None, - ) - assert out.board_yaml == "/work/proj/board.yaml" - assert out.slices[0].env["ALP_SDK_ROOT"] == str(sdk_root) - assert demoted == [] - - -def test_slice_confined_toolchain_root_is_demoted_not_a_hard_error(sdk_root): - """tan-cli #89: an unresolved ${TOOLCHAIN_ROOT} confined to one slice's - own field must not fail the whole substitution pass -- it comes back as - a SliceDemotion for the executor to route through - executionPolicy.missingTool at dispatch instead of erroring here.""" - json = """{ - "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", - "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", - "slices": [ - { "coreId": "m33_sm", "backend": "zephyr", "buildDir": "build/c1", "appDir": "app", - "configArtefacts": [], "toolchain": null, "artifacts": {}, "debug": {}, - "command": { "tool": "west", "args": ["build"], "cwd": "build/c1" }, - "env": { "ZEPHYR_SDK_INSTALL_DIR": "${TOOLCHAIN_ROOT}" }, "envAppendPath": {} } - ], - "sharedArtefacts": [], "warnings": [] - }""" - plan = parse_build_plan(json) - out, demoted = apply_plan_token_substitution( - plan, - board_yaml_path="/work/proj/board.yaml", - exec_base="/work/proj", - sdk_root=str(sdk_root), - python="python3", - toolchain_root=None, - ) - assert len(demoted) == 1 - d = demoted[0] - assert d.slice_index == 0 - assert d.core_id == "m33_sm" - assert "slices[0].env.ZEPHYR_SDK_INSTALL_DIR" in d.reason - assert "ZEPHYR_SDK_INSTALL_DIR" in d.reason or "west sdk install" in d.reason - # The literal token survives in the (never-dispatched) output plan -- - # not substituted blank. - assert out.slices[0].env["ZEPHYR_SDK_INSTALL_DIR"] == "${TOOLCHAIN_ROOT}" - - -def test_leftover_token_after_substitution_is_refused(sdk_root): - json = """{ - "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", - "boardYaml": "${UNKNOWN}/board.yaml", "sku": "S", "buildRoot": "build", - "slices": [], "sharedArtefacts": [], "warnings": [] - }""" - plan = parse_build_plan(json) - with pytest.raises(TokenSubstitutionError) as e: - apply_plan_token_substitution( - plan, - board_yaml_path="/work/proj/board.yaml", - exec_base="/work/proj", - sdk_root=str(sdk_root), - python="python3", - toolchain_root=None, - ) - assert e.value.code == "build.plan-token-unresolved" - assert "${UNKNOWN}" in e.value.message - - -def test_missing_git_head_is_no_signal_not_a_hard_error(sdk_root): - """A resolved SDK root that is NOT a git checkout at all (no .git) -- the - sdkCommit guard must treat "could not resolve HEAD" as no signal (an SDK - release tarball is a normal, supported setup), not fail the build.""" - json = """{ - "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", "sdkCommit": "deadbee", - "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", - "slices": [], "sharedArtefacts": [], "warnings": [] - }""" - plan = parse_build_plan(json) - out, demoted = apply_plan_token_substitution( - plan, - board_yaml_path="/work/proj/board.yaml", - exec_base="/work/proj", - sdk_root=str(sdk_root), - python="python3", - toolchain_root=None, - ) - assert demoted == [] - - -@pytest.mark.skipif(shutil.which("git") is None, reason="git must be on PATH for this test") -def test_sdk_commit_mismatch_is_refused(sdk_root): - def git(*args): - # `encoding=`, not bare `text=True`: git localises its own messages, so - # `check=True` capturing a failure decodes them with the platform locale - # and a `UnicodeDecodeError` would replace the real assertion. - return subprocess.run( - ["git", "-C", str(sdk_root), *args], capture_output=True, text=True, - encoding="utf-8", errors="replace", check=True, - ) - - git("init", "-q") - git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-q", "-m", "x") - head = git("rev-parse", "--short", "HEAD").stdout.strip() - - json = """{ - "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", "sdkCommit": "0000000", - "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", - "slices": [], "sharedArtefacts": [], "warnings": [] - }""" - plan = parse_build_plan(json) - assert plan.sdk_commit != head - with pytest.raises(TokenSubstitutionError) as e: - apply_plan_token_substitution( - plan, - board_yaml_path="/work/proj/board.yaml", - exec_base="/work/proj", - sdk_root=str(sdk_root), - python="python3", - toolchain_root=None, - ) - assert e.value.code == "build.sdk-commit-mismatch" - assert "0000000" in e.value.message - - -@pytest.mark.skipif(shutil.which("git") is None, reason="git must be on PATH for this test") -def test_sdk_commit_match_does_not_refuse(sdk_root): - def git(*args): - # See the sibling above: explicit `encoding=`, never the platform locale. - return subprocess.run( - ["git", "-C", str(sdk_root), *args], capture_output=True, text=True, - encoding="utf-8", errors="replace", check=True, - ) - - git("init", "-q") - git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-q", "-m", "x") - head = git("rev-parse", "--short", "HEAD").stdout.strip() - - json = f"""{{ - "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", "sdkCommit": "{head}", - "boardYaml": "${{PROJECT_ROOT}}/board.yaml", "sku": "S", "buildRoot": "build", - "slices": [], "sharedArtefacts": [], "warnings": [] - }}""" - plan = parse_build_plan(json) - out, demoted = apply_plan_token_substitution( - plan, - board_yaml_path="/work/proj/board.yaml", - exec_base="/work/proj", - sdk_root=str(sdk_root), - python="python3", - toolchain_root=None, - ) - assert demoted == [] +# SPDX-License-Identifier: Apache-2.0 +import shutil +import subprocess + +import pytest + +from tan.commands.build.token_substitution import ( + TokenSubstitutionError, + apply_plan_token_substitution, +) +from tan.core.build_plan import parse_build_plan + +LEGACY_PLAN = """{ + "schemaVersion": 1, "generatedBy": "g", "boardYaml": "/work/proj/board.yaml", "sku": "S", "buildRoot": "build", + "slices": [], "sharedArtefacts": [], "warnings": [] +}""" + + +@pytest.fixture +def sdk_root(tmp_path): + """A bare directory standing in for a resolved alp-sdk checkout -- this + layer takes `sdk_root` pre-resolved (unlike tan-cli's real resolver), so + the fixture only needs to exist on disk for `git -C ` to be + meaningful.""" + d = tmp_path / "sdk" + d.mkdir() + return d + + +def test_legacy_plan_is_untouched_no_op(): + plan = parse_build_plan(LEGACY_PLAN) + out, demoted = apply_plan_token_substitution( + plan, + board_yaml_path="/work/proj/board.yaml", + exec_base="/work/proj", + sdk_root="/opt/alp-sdk", + python="python3", + toolchain_root=None, + ) + assert out == plan + assert demoted == [] + + +def test_unknown_plan_path_mode_is_refused_before_any_guard_runs(): + """A board.yaml/exec_base pair that would ALSO fail the divergence guard + -- proving the unknown-mode check short-circuits first.""" + json = """{ + "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened-v2", + "boardYaml": "/work/proj/examples/foo/board.yaml", "sku": "S", "buildRoot": "build", + "slices": [], "sharedArtefacts": [], "warnings": [] + }""" + plan = parse_build_plan(json) + with pytest.raises(TokenSubstitutionError) as e: + apply_plan_token_substitution( + plan, + board_yaml_path="/work/proj/examples/foo/board.yaml", + exec_base="/work/proj", + sdk_root="/opt/alp-sdk", + python="python3", + toolchain_root=None, + ) + assert e.value.code == "build.plan-invalid" + assert "tokened-v2" in e.value.message + + +def test_missing_board_yaml_path_is_refused(): + json = """{ + "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", + "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", + "slices": [], "sharedArtefacts": [], "warnings": [] + }""" + plan = parse_build_plan(json) + with pytest.raises(TokenSubstitutionError) as e: + apply_plan_token_substitution( + plan, + board_yaml_path=None, + exec_base="/work/proj", + sdk_root="/opt/alp-sdk", + python="python3", + toolchain_root=None, + ) + assert e.value.code == "build.plan-invalid" + + +def test_project_root_mismatch_is_refused(): + json = """{ + "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", + "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", + "slices": [], "sharedArtefacts": [], "warnings": [] + }""" + plan = parse_build_plan(json) + # board.yaml lives nested under the workspace root, but the exec base + # stays the workspace root itself -- a real PROJECT_ROOT/exec-base split. + with pytest.raises(TokenSubstitutionError) as e: + apply_plan_token_substitution( + plan, + board_yaml_path="/work/proj/examples/foo/board.yaml", + exec_base="/work/proj", + sdk_root="/opt/alp-sdk", + python="python3", + toolchain_root=None, + ) + assert e.value.code == "build.project-root-mismatch" + assert "examples/foo" in e.value.message + + +def test_unresolved_sdk_root_is_refused_not_substituted_empty(): + """Regression: a tokened plan with no resolvable sdk_root must not + degrade ${SDK_ROOT} to "" -- turning ${SDK_ROOT}/scripts into the bare + /scripts sails right past the leftover-token guard.""" + json = """{ + "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", + "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", + "slices": [ + { "coreId": "c1", "backend": "zephyr", "buildDir": "build/c1", "appDir": "app", + "configArtefacts": [], "toolchain": null, "artifacts": {}, "debug": {}, + "command": { "tool": "west", "args": ["build"], "cwd": "build/c1" }, + "env": { "ALP_SDK_ROOT": "${SDK_ROOT}/scripts" }, "envAppendPath": {} } + ], + "sharedArtefacts": [], "warnings": [] + }""" + plan = parse_build_plan(json) + with pytest.raises(TokenSubstitutionError) as e: + apply_plan_token_substitution( + plan, + board_yaml_path="/work/proj/board.yaml", + exec_base="/work/proj", + sdk_root=None, + python="python3", + toolchain_root=None, + ) + assert e.value.code == "build.sdk-root-unresolved" + + +def test_tokened_plan_with_matching_project_root_substitutes(sdk_root): + json = """{ + "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", + "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", + "slices": [ + { "coreId": "c1", "backend": "zephyr", "buildDir": "build/c1", "appDir": "app", + "configArtefacts": [], "toolchain": null, "artifacts": {}, "debug": {}, + "command": { "tool": "west", "args": ["build"], "cwd": "build/c1" }, + "env": { "ALP_SDK_ROOT": "${SDK_ROOT}" }, "envAppendPath": {} } + ], + "sharedArtefacts": [], "warnings": [] + }""" + plan = parse_build_plan(json) + out, demoted = apply_plan_token_substitution( + plan, + board_yaml_path="/work/proj/board.yaml", + exec_base="/work/proj", + sdk_root=str(sdk_root), + python="python3", + toolchain_root=None, + ) + assert out.board_yaml == "/work/proj/board.yaml" + assert out.slices[0].env["ALP_SDK_ROOT"] == str(sdk_root) + assert demoted == [] + + +def test_slice_confined_toolchain_root_is_demoted_not_a_hard_error(sdk_root): + """tan-cli #89: an unresolved ${TOOLCHAIN_ROOT} confined to one slice's + own field must not fail the whole substitution pass -- it comes back as + a SliceDemotion for the executor to route through + executionPolicy.missingTool at dispatch instead of erroring here.""" + json = """{ + "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", + "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", + "slices": [ + { "coreId": "m33_sm", "backend": "zephyr", "buildDir": "build/c1", "appDir": "app", + "configArtefacts": [], "toolchain": null, "artifacts": {}, "debug": {}, + "command": { "tool": "west", "args": ["build"], "cwd": "build/c1" }, + "env": { "ZEPHYR_SDK_INSTALL_DIR": "${TOOLCHAIN_ROOT}" }, "envAppendPath": {} } + ], + "sharedArtefacts": [], "warnings": [] + }""" + plan = parse_build_plan(json) + out, demoted = apply_plan_token_substitution( + plan, + board_yaml_path="/work/proj/board.yaml", + exec_base="/work/proj", + sdk_root=str(sdk_root), + python="python3", + toolchain_root=None, + ) + assert len(demoted) == 1 + d = demoted[0] + assert d.slice_index == 0 + assert d.core_id == "m33_sm" + assert "slices[0].env.ZEPHYR_SDK_INSTALL_DIR" in d.reason + assert "ZEPHYR_SDK_INSTALL_DIR" in d.reason or "west sdk install" in d.reason + # The literal token survives in the (never-dispatched) output plan -- + # not substituted blank. + assert out.slices[0].env["ZEPHYR_SDK_INSTALL_DIR"] == "${TOOLCHAIN_ROOT}" + + +def test_leftover_token_after_substitution_is_refused(sdk_root): + json = """{ + "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", + "boardYaml": "${UNKNOWN}/board.yaml", "sku": "S", "buildRoot": "build", + "slices": [], "sharedArtefacts": [], "warnings": [] + }""" + plan = parse_build_plan(json) + with pytest.raises(TokenSubstitutionError) as e: + apply_plan_token_substitution( + plan, + board_yaml_path="/work/proj/board.yaml", + exec_base="/work/proj", + sdk_root=str(sdk_root), + python="python3", + toolchain_root=None, + ) + assert e.value.code == "build.plan-token-unresolved" + assert "${UNKNOWN}" in e.value.message + + +def test_missing_git_head_is_no_signal_not_a_hard_error(sdk_root): + """A resolved SDK root that is NOT a git checkout at all (no .git) -- the + sdkCommit guard must treat "could not resolve HEAD" as no signal (an SDK + release tarball is a normal, supported setup), not fail the build.""" + json = """{ + "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", "sdkCommit": "deadbee", + "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", + "slices": [], "sharedArtefacts": [], "warnings": [] + }""" + plan = parse_build_plan(json) + out, demoted = apply_plan_token_substitution( + plan, + board_yaml_path="/work/proj/board.yaml", + exec_base="/work/proj", + sdk_root=str(sdk_root), + python="python3", + toolchain_root=None, + ) + assert demoted == [] + + +@pytest.mark.skipif(shutil.which("git") is None, reason="git must be on PATH for this test") +def test_sdk_commit_mismatch_is_refused(sdk_root): + def git(*args): + # `encoding=`, not bare `text=True`: git localises its own messages, so + # `check=True` capturing a failure decodes them with the platform locale + # and a `UnicodeDecodeError` would replace the real assertion. + return subprocess.run( + ["git", "-C", str(sdk_root), *args], capture_output=True, text=True, + encoding="utf-8", errors="replace", check=True, + ) + + git("init", "-q") + git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-q", "-m", "x") + head = git("rev-parse", "--short", "HEAD").stdout.strip() + + json = """{ + "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", "sdkCommit": "0000000", + "boardYaml": "${PROJECT_ROOT}/board.yaml", "sku": "S", "buildRoot": "build", + "slices": [], "sharedArtefacts": [], "warnings": [] + }""" + plan = parse_build_plan(json) + assert plan.sdk_commit != head + with pytest.raises(TokenSubstitutionError) as e: + apply_plan_token_substitution( + plan, + board_yaml_path="/work/proj/board.yaml", + exec_base="/work/proj", + sdk_root=str(sdk_root), + python="python3", + toolchain_root=None, + ) + assert e.value.code == "build.sdk-commit-mismatch" + assert "0000000" in e.value.message + + +@pytest.mark.skipif(shutil.which("git") is None, reason="git must be on PATH for this test") +def test_sdk_commit_match_does_not_refuse(sdk_root): + def git(*args): + # See the sibling above: explicit `encoding=`, never the platform locale. + return subprocess.run( + ["git", "-C", str(sdk_root), *args], capture_output=True, text=True, + encoding="utf-8", errors="replace", check=True, + ) + + git("init", "-q") + git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-q", "-m", "x") + head = git("rev-parse", "--short", "HEAD").stdout.strip() + + json = f"""{{ + "schemaVersion": 1, "generatedBy": "g", "planPathMode": "tokened", "sdkCommit": "{head}", + "boardYaml": "${{PROJECT_ROOT}}/board.yaml", "sku": "S", "buildRoot": "build", + "slices": [], "sharedArtefacts": [], "warnings": [] + }}""" + plan = parse_build_plan(json) + out, demoted = apply_plan_token_substitution( + plan, + board_yaml_path="/work/proj/board.yaml", + exec_base="/work/proj", + sdk_root=str(sdk_root), + python="python3", + toolchain_root=None, + ) + assert demoted == [] diff --git a/python/tests/commands/test_completion_command.py b/python/tests/commands/test_completion_command.py new file mode 100644 index 00000000..c6d9ab52 --- /dev/null +++ b/python/tests/commands/test_completion_command.py @@ -0,0 +1,339 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan completion` -- CLI surface tests. + +`completion` is not registered in `tan.cli.app` by this change (the shared +`cli.py` registration point is owned by the orchestrator wiring commands in +parallel), so these tests mount the command on a throwaway `typer.Typer()` +rather than importing `tan.cli.app` -- matching `test_faultdecode_command.py`'s +own note for the same situation. + +Named twin: `crates/tan-cli/src/commands/completion.rs`'s `#[cfg(test)] mod +tests`. `test_resolve_shell_defaults_and_normalizes` and +`test_embedded_scripts_are_nonempty_and_shell_specific` mirror its +`resolve_shell_defaults_and_normalizes`/`scripts_are_nonempty_and_shell_specific` +1:1; `test_embedded_scripts_list_every_registered_subcommand` mirrors its +`embedded_scripts_list_every_cli_command` (this port's cheaper equivalent -- +`tan.cli._SUBCOMMAND_NAMES` stands in for walking clap's built command graph). +This port has no twin of the oracle's `completion_scripts_match_clap_flags_ +exactly` gate (there is no local clap graph to diff against): the three +scripts are frozen, byte-for-byte captures of the oracle's own already-gated +output, not derived from a live command graph here, so there is no +independent flag table this port could drift out of sync with. + +Every value in this file was confirmed against the built oracle +(`target/debug/tan.exe`, reports `tan 0.4.1` -- see +`tests/parity/oracle.py:219`'s `PINNED_ORACLE_VERSION`, the one place that +spelling is owned): `tan completion --shell + [--format json]`, `tan completion --shell +[--format json]`, and the JSON `data.script` values these tests assert +`BASH_SCRIPT`/`ZSH_SCRIPT`/`FISH_SCRIPT` equal were extracted byte-for-byte +from the oracle's own `--format json` output, not retyped by hand. +""" + +from __future__ import annotations + +import json + +import pytest +import typer +from typer.testing import CliRunner + +from tan.commands.completion_cmd import ( + BASH_SCRIPT, + FISH_SCRIPT, + SHELL_UNSUPPORTED_CODE, + SHELL_UNSUPPORTED_MESSAGE, + SHELL_UNSUPPORTED_TEXT_LINE, + ZSH_SCRIPT, + completion, + resolve_shell, + script_for, +) + +app = typer.Typer() +app.command("completion")(completion) +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# `resolve_shell` / `script_for` -- twins of completion.rs's own unit tests +# --------------------------------------------------------------------------- + + +def test_resolve_shell_defaults_and_normalizes(): + assert resolve_shell(None) == "bash" + assert resolve_shell(" ZSH ") == "zsh" + assert resolve_shell("fish") == "fish" + assert resolve_shell("tcsh") is None + assert resolve_shell("") is None # blank is not "absent" -- only None defaults + + +def test_script_for_selects_the_matching_script(): + assert script_for("bash") == BASH_SCRIPT + assert script_for("zsh") == ZSH_SCRIPT + assert script_for("fish") == FISH_SCRIPT + + +def test_script_for_unrecognised_value_falls_back_to_bash(): + """Mirrors the oracle's `script_for` match arm (`_ => BASH_SCRIPT`). + Unreachable from `completion()` itself -- `resolve_shell` already rejects + anything this would matter for -- kept as its own unit so the fallback + stays intentional if a future caller reaches `script_for` directly.""" + assert script_for("tcsh") == BASH_SCRIPT + + +def test_embedded_scripts_are_nonempty_and_shell_specific(): + assert "_tan_complete" in BASH_SCRIPT + assert BASH_SCRIPT.startswith("# tan CLI bash completion") + assert "#compdef tan" in ZSH_SCRIPT + assert "__fish_use_subcommand" in FISH_SCRIPT + # Every script ends in exactly one trailing newline (this command's own + # `print(script)` adds the second one the oracle's stdout capture shows). + for script in (BASH_SCRIPT, ZSH_SCRIPT, FISH_SCRIPT): + assert script.endswith("\n") + assert not script.endswith("\n\n") + + +def test_embedded_scripts_list_every_registered_subcommand(): + """Drift guard: every verb `tan.cli` registers must tab-complete on all + three shells. Reads `tan.cli._SUBCOMMAND_NAMES` (a frozenset this file + only imports, never edits) rather than hand-duplicating the 32-name list a + third time -- the same reasoning the oracle's own + `embedded_scripts_list_every_cli_command` gives for reading clap's command + graph instead of a hand-kept copy. Word-boundary, not substring: a bare + `.contains` would also match a name that is a fragment of an unrelated + token (e.g. "run" inside a longer word).""" + from tan.cli import _SUBCOMMAND_NAMES + + def script_lists(script: str, name: str) -> bool: + tokens = set() + current = [] + for ch in script: + if ch.isalnum() or ch == "-": + current.append(ch) + else: + if current: + tokens.add("".join(current)) + current = [] + if current: + tokens.add("".join(current)) + return name in tokens + + missing = [ + (shell, name) + for name in _SUBCOMMAND_NAMES + for shell, script in (("bash", BASH_SCRIPT), ("zsh", ZSH_SCRIPT), ("fish", FISH_SCRIPT)) + if not script_lists(script, name) + ] + assert missing == [] + + +# --------------------------------------------------------------------------- +# CLI surface -- success paths +# --------------------------------------------------------------------------- + + +def test_default_shell_is_bash_when_shell_flag_absent(): + result = runner.invoke(app, []) + assert result.exit_code == 0 + assert result.stdout == BASH_SCRIPT + "\n" + assert result.stderr == "" + + +@pytest.mark.parametrize( + ("shell_arg", "expected"), + [ + ("bash", BASH_SCRIPT), + ("zsh", ZSH_SCRIPT), + ("fish", FISH_SCRIPT), + (" ZSH ", ZSH_SCRIPT), # trimmed + lowercased, like the oracle + ("Fish", FISH_SCRIPT), + ], +) +def test_shell_flag_selects_the_right_script_text_mode(shell_arg, expected): + """Text mode prints the script straight to stdout (the payload itself, + not a `- ` line on stderr) and nothing to stderr -- matching + `completion.rs`'s own `println!` plus its comment on why: `eval "$(tan + completion --shell zsh)"` and `> file` both read stdout.""" + result = runner.invoke(app, ["--shell", shell_arg]) + assert result.exit_code == 0 + assert result.stdout == expected + "\n" + assert result.stderr == "" + + +def test_json_mode_success_envelope_matches_the_oracle_shape(): + result = runner.invoke(app, ["--shell", "zsh", "--format", "json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc == { + "command": "completion", + "ok": True, + "exitCode": 0, + "project": {"root": None, "boardYaml": None}, + "data": {"schemaVersion": "1", "shell": "zsh", "script": ZSH_SCRIPT}, + "issues": [], + } + # No `sdk` key at all (absent, not null) -- completion resolves no checkout. + assert "sdk" not in doc + assert result.stderr == "" + + +def test_json_mode_default_shell_is_bash(): + result = runner.invoke(app, ["--format", "json"]) + doc = json.loads(result.stdout) + assert doc["data"]["shell"] == "bash" + assert doc["data"]["script"] == BASH_SCRIPT + + +# --------------------------------------------------------------------------- +# CLI surface -- the one failure mode: an unsupported --shell value +# --------------------------------------------------------------------------- + + +def test_unsupported_shell_text_mode_reports_to_stderr_and_exits_one(): + """Verbatim against the oracle: stdout stays EMPTY on this path (measured: + `tan.exe completion --shell powershell` writes nothing to stdout), and the + error line goes to stderr, matching every other command's text-mode error + convention.""" + result = runner.invoke(app, ["--shell", "powershell"]) + assert result.exit_code == 1 + assert result.stdout == "" + assert result.stderr == SHELL_UNSUPPORTED_TEXT_LINE + "\n" + + +def test_unsupported_shell_json_mode_matches_the_oracle_envelope(): + result = runner.invoke(app, ["--shell", "powershell", "--format", "json"]) + assert result.exit_code == 1 + doc = json.loads(result.stdout) + assert doc == { + "command": "completion", + "ok": False, + "exitCode": 1, + "project": {"root": None, "boardYaml": None}, + # `shell` falls back to "bash" and `script` is empty on this path -- + # verbatim from the oracle's own error-path `CompletionData`. + "data": {"schemaVersion": "1", "shell": "bash", "script": ""}, + "issues": [ + { + "code": SHELL_UNSUPPORTED_CODE, + "severity": "error", + "message": SHELL_UNSUPPORTED_MESSAGE, + } + ], + } + + +def test_blank_shell_value_is_also_unsupported(): + """A literal empty `--shell ""` is NOT "absent" (that is `None`, the + no-flag-at-all case, which defaults to bash) -- `resolve_shell("")` trims + to `""`, which matches none of `bash`/`zsh`/`fish`.""" + result = runner.invoke(app, ["--shell", ""]) + assert result.exit_code == 1 + assert result.stderr == SHELL_UNSUPPORTED_TEXT_LINE + "\n" + + +# --------------------------------------------------------------------------- +# Global-flag surface (clap `GlobalArgs`, `global = true`) -- accepted, unused +# --------------------------------------------------------------------------- + + +def test_every_global_flag_is_accepted_without_erroring(): + """`tan completion --ci` (etc.) must exit 0, not a Click usage error -- + clap accepts every one of these on every subcommand. Mirrors + `clean_cmd.clean`'s identical precedent.""" + result = runner.invoke( + app, + [ + "--shell", + "bash", + "--project", + "some/project", + "--board-yaml", + "some/board.yaml", + "--sdk-root", + "some/sdk", + "--quiet", + "--verbose", + "--no-color", + "--non-interactive", + "--ci", + "--target", + "zephyr-conf", + "--all", + ], + ) + assert result.exit_code == 0 + assert result.stdout == BASH_SCRIPT + "\n" + + +def test_empty_format_value_is_a_parse_error(): + """Verified against the oracle: `tan completion --format ""` exits 2 on + the value itself ("a value is required for '--format '"), not a + silent fallback to text mode.""" + result = runner.invoke(app, ["--format", ""]) + assert result.exit_code == 2 + + +def test_unrecognised_flag_is_a_usage_error(): + """Verified against the oracle: an unknown flag is `error: unexpected + argument '--bogus' found`, exit 2 -- clap's own parse-error shape, which + Click's default (undecorated) `app.command()` registration already + reproduces without any `ignore_unknown_options` context setting.""" + result = runner.invoke(app, ["--bogus"]) + assert result.exit_code == 2 + + +def test_unexpected_positional_is_a_usage_error(): + """Verified against the oracle: `tan completion badarg` is `error: + unexpected argument 'badarg' found`, exit 2 -- `completion` takes no + positional at all.""" + result = runner.invoke(app, ["badarg"]) + assert result.exit_code == 2 + + +# --------------------------------------------------------------------------- +# `--format json` before the subcommand name (clap `global = true`) +# --------------------------------------------------------------------------- + + +def test_format_json_before_subcommand_reads_off_ctx_obj(): + """Mirrors `test_faultdecode_command.py`'s identical test: `cli.py`'s + `root` callback stashes a leading `--format` on `ctx.obj`, and a command + that has joined `_HONOURS_ROOT_FORMAT` reads it back. Confirmed live + against the built oracle: `tan --format json completion --shell zsh` and + `tan completion --shell zsh --format json` print byte-identical JSON at + rc=0, because the oracle's clap `--format` is `global = true`. `cli.py` + itself is not touched by this change -- see this module's own docstring -- + so this mounts the same throwaway root callback `test_faultdecode_ + command.py` uses rather than the real one.""" + root_app = typer.Typer() + + @root_app.callback(invoke_without_command=True) + def _root(ctx: typer.Context, output_format: str = typer.Option(None, "--format")) -> None: + ctx.obj = {"format": output_format} + + root_app.command("completion")(completion) + result = runner.invoke(root_app, ["--format", "json", "completion", "--shell", "zsh"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["data"]["shell"] == "zsh" + assert doc["data"]["script"] == ZSH_SCRIPT + + +def test_subcommand_format_overrides_a_leading_root_format(): + """`--format` declared after the subcommand name still wins over a + leading root-position value, matching `debug_config_cmd.debug_config`'s + identical `output_format or ctx.obj...` precedence (here spelled `is not + None`, per this file's own fixed version of that fallback).""" + root_app = typer.Typer() + + @root_app.callback(invoke_without_command=True) + def _root(ctx: typer.Context, output_format: str = typer.Option(None, "--format")) -> None: + ctx.obj = {"format": output_format} + + root_app.command("completion")(completion) + result = runner.invoke( + root_app, ["--format", "json", "completion", "--shell", "bash", "--format", "text"] + ) + assert result.exit_code == 0 + assert result.stdout == BASH_SCRIPT + "\n" diff --git a/python/tests/commands/test_debug_config_command.py b/python/tests/commands/test_debug_config_command.py index 7f81c225..1b8dc758 100644 --- a/python/tests/commands/test_debug_config_command.py +++ b/python/tests/commands/test_debug_config_command.py @@ -418,6 +418,7 @@ def boom(**_kwargs): server=None, core=None, pre_launch_task=None, + gdbserver_address=None, svd=None, preview=False, project=None, @@ -815,3 +816,159 @@ def test_an_out_of_range_source_date_epoch_still_emits_one_envelope(epoch, tmp_p payload = json.loads(proc.stdout) # exactly one parseable document assert payload["command"] == "debug-config" assert payload["ok"] is True + + +# --------------------------------------------------------------------------- +# tan-cli#138: the restored v0.3.1 preLaunchTask default, end to end. +# --------------------------------------------------------------------------- + + +def test_a_default_run_names_its_v031_pre_launch_task(tmp_path): + """Formerly the CLI-level pairing of `no_profile_names_a_pre_launch_task_ + by_default`: a plain run with no `--pre-launch-task` used to emit NO + key. tan-cli#138 (maintainer decision) restores the v0.3.1 default -- the + pure-logic contract itself lives in `tests/core/test_debug_launch.py`; + this proves the CLI actually wires it through end to end.""" + env = envelope( + run_cli(tmp_path, "--target-kind", ZEPHYR_MCU, "--server", JLINK, + "--preview", "--format", "json") + ) + assert env["data"]["configuration"]["preLaunchTask"] == "alp: build active target" + + +def test_pre_launch_task_empty_string_opts_out_over_the_cli(tmp_path): + """`--pre-launch-task ''` reaches the same opt-out `create_launch_draft` + exercises directly -- proven here through actual argv parsing, since an + empty-string CLI value is its own trap (typer/click could plausibly treat + it as "not passed").""" + env = envelope( + run_cli(tmp_path, "--target-kind", ZEPHYR_MCU, "--server", JLINK, + "--pre-launch-task", "", "--preview", "--format", "json") + ) + assert "preLaunchTask" not in env["data"]["configuration"] + + +# --------------------------------------------------------------------------- +# tan-cli#321: miDebuggerServerAddress needs a hand-filled value. +# --------------------------------------------------------------------------- + + +def test_yocto_preview_reports_the_gdbserver_address_info_issue_by_default(tmp_path): + env = envelope( + run_cli(tmp_path, "--target-kind", YOCTO_USERSPACE, "--server", GDBSERVER, + "--preview", "--format", "json") + ) + assert env["exitCode"] == 0 + assert env["data"]["configuration"]["miDebuggerServerAddress"] == ":" + # tan-cli#138 vs #321: yocto-userspace carries NO restored preLaunchTask + # default (unlike the other three target classes -- DEFAULT_PRE_LAUNCH_ + # TASK in tan/core/debug_launch.py deliberately omits it), so the issue + # message must say so rather than claiming a default that does not exist. + assert "preLaunchTask" not in env["data"]["configuration"] + issue = next( + (i for i in env["issues"] if i["code"] == "debug-config.gdbserver-address-unresolved"), + None, + ) + assert issue is not None and issue["severity"] == "info" + assert "--gdbserver-address" in issue["message"] + assert "carries no `preLaunchTask` reminder" in issue["message"] + assert "--pre-launch-task" in issue["message"] + + +def test_yocto_write_reports_the_gdbserver_address_info_issue_too(tmp_path): + """The write-path counterpart of the preview test above (tan-cli#321): the + issue is built from the FINAL `configuration` in both branches of the + `success()` closure in `debug_config_cmd.py`, not only the `--preview` + one -- a mutation collapsing the write branch's own check (`if target == + YOCTO_USERSPACE` -> `if False`) killed no test before this, because + every assertion of this issue firing lived on the `--preview` case + only.""" + env = envelope( + run_cli(tmp_path, "--target-kind", YOCTO_USERSPACE, "--server", GDBSERVER, "--format", "json") + ) + assert env["exitCode"] == 0 + assert env["data"]["preview"] is False + assert env["data"]["configuration"]["miDebuggerServerAddress"] == ":" + codes = [i["code"] for i in env["issues"]] + assert "debug-config.gdbserver-address-unresolved" in codes + on_disk = json.loads(launch_json(tmp_path).read_text(encoding="utf-8")) + assert ( + on_disk["configurations"][0]["miDebuggerServerAddress"] == ":" + ) + + +def test_gdbserver_address_flag_fills_the_field_and_drops_the_issue(tmp_path): + env = envelope( + run_cli(tmp_path, "--target-kind", YOCTO_USERSPACE, "--server", GDBSERVER, + "--gdbserver-address", "192.168.10.42:3333", "--preview", "--format", "json") + ) + assert env["exitCode"] == 0 + assert env["data"]["configuration"]["miDebuggerServerAddress"] == "192.168.10.42:3333" + codes = [i["code"] for i in env["issues"]] + assert "debug-config.gdbserver-address-unresolved" not in codes + + +def test_gdbserver_address_on_a_target_kind_without_the_field_says_so(tmp_path): + env = envelope( + run_cli(tmp_path, "--target-kind", ZEPHYR_MCU, "--server", JLINK, + "--gdbserver-address", "192.168.10.42:3333", "--preview", "--format", "json") + ) + assert "miDebuggerServerAddress" not in env["data"]["configuration"] + assert any("--gdbserver-address was given" in n for n in env["data"]["notes"]), ( + "accepting --gdbserver-address here in silence is the no-op this note exists to prevent" + ) + # Not a yocto-userspace draft, so the tan-cli#321 issue must not fire either. + codes = [i["code"] for i in env["issues"]] + assert "debug-config.gdbserver-address-unresolved" not in codes + + +def test_an_empty_gdbserver_address_fails_instead_of_writing(tmp_path): + """The same floor `--svd` holds for its own path argument: falling back to + "no address" on an explicitly empty value would make a typo (or a copy- + paste mistake) indistinguishable from not passing the flag at all.""" + env = envelope( + run_cli(tmp_path, "--target-kind", YOCTO_USERSPACE, "--server", GDBSERVER, + "--gdbserver-address", "", "--preview", "--format", "json") + ) + assert env["exitCode"] == 5 + assert "empty value" in env["issues"][0]["message"] + assert not launch_json(tmp_path).exists() + + +def test_a_hand_typed_gdbserver_address_survives_a_rerun_and_is_not_re_nagged(tmp_path): + """tan-cli#321's info issue is checked against what this run actually + WRITES, not the pre-merge draft: a customer who already filled in the + real address must not be nagged about it forever. Companion to the Rust + `a_hand_typed_gdbserver_address_survives_the_host_port_placeholder` + (`crates/tan-core/src/debug_launch.rs`), which covers the merge itself; + this proves the ISSUE follows the same outcome.""" + launch_json(tmp_path).parent.mkdir() + launch_json(tmp_path).write_text( + json.dumps( + { + "version": "0.2.0", + "configurations": [ + { + "name": "Alp: Yocto Remote Debug", + "type": "cppdbg", + "request": "launch", + "miDebuggerServerAddress": "192.168.10.42:3333", + "miDebuggerPath": "/opt/gdb/bin/aarch64-poky-linux-gdb", + } + ], + }, + indent=2, + ), + encoding="utf-8", + ) + + env = envelope( + run_cli(tmp_path, "--target-kind", YOCTO_USERSPACE, "--server", GDBSERVER, "--format", "json") + ) + + assert env["exitCode"] == 0 + assert env["data"]["configuration"]["miDebuggerServerAddress"] == "192.168.10.42:3333" + codes = [i["code"] for i in env["issues"]] + assert "debug-config.gdbserver-address-unresolved" not in codes + on_disk = json.loads(launch_json(tmp_path).read_text(encoding="utf-8")) + assert on_disk["configurations"][0]["miDebuggerServerAddress"] == "192.168.10.42:3333" diff --git a/python/tests/commands/test_deferred_commands.py b/python/tests/commands/test_deferred_commands.py deleted file mode 100644 index 8cb7cdbb..00000000 --- a/python/tests/commands/test_deferred_commands.py +++ /dev/null @@ -1,107 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -"""The seven not-yet-ported verbs (`scaffold`, `completion`, `diff`, `pinmux`, -`inspect`, `trace`, `support-bundle`) must each RESOLVE -- not fall through to -Typer's unknown-command usage error -- and refuse with the one shared, -documented `cli.command-deferred` code, at `RUNTIME_FAILURE` (1), naming -tan-cli#260. See `tan/commands/deferred_cmd.py`'s module docstring for why -that exit code and that single shared code were chosen over the alternatives. -""" -from __future__ import annotations - -import json - -import pytest -from typer.testing import CliRunner - -from tan.cli import _HONOURS_ROOT_FORMAT, app -from tan.commands.deferred_cmd import DEFERRED_ISSUE_CODE, DEFERRED_ISSUE_URL, DEFERRED_VERBS -from tan.exit_codes import ExitCode - -runner = CliRunner() - -# `DEFERRED_VERBS` is imported from `deferred_cmd` (the module that owns the -# seven stubs) rather than retyped here as a THIRD copy of the same names -- -# `test_the_verb_list_here_matches_the_deferred_module` below still guards it -# against drift independently, by introspecting the stub callables themselves. - - -@pytest.mark.parametrize("verb", DEFERRED_VERBS) -def test_deferred_verb_resolves_with_the_shared_code_and_exit(verb): - """A bare invocation: resolves (not Click's exit-2 unknown-command path), - exits `RUNTIME_FAILURE`, and the JSON envelope carries the shared code.""" - result = runner.invoke(app, [verb, "--format", "json"]) - assert result.exit_code == int(ExitCode.RUNTIME_FAILURE) - envelope = json.loads(result.stdout) - assert envelope["exitCode"] == int(ExitCode.RUNTIME_FAILURE) - assert envelope["ok"] is False - assert len(envelope["issues"]) == 1 - issue = envelope["issues"][0] - assert issue["code"] == DEFERRED_ISSUE_CODE - assert "v0.6.0" in issue["message"] - assert DEFERRED_ISSUE_URL in issue["message"] - - -@pytest.mark.parametrize("verb", DEFERRED_VERBS) -def test_deferred_verb_text_mode_exits_runtime_failure_not_usage_error(verb): - """Text mode (the default): same exit code, no traceback, and stdout - carries nothing -- the same "stdout is the envelope channel only in JSON - mode" contract every other command holds.""" - result = runner.invoke(app, [verb]) - assert result.exit_code == int(ExitCode.RUNTIME_FAILURE) - assert result.exit_code != 2 # not Click's unknown-command usage error - assert result.stdout == "", "stdout is the envelope channel in text mode too" - - -@pytest.mark.parametrize("verb", DEFERRED_VERBS) -def test_deferred_verb_honours_root_position_format(verb): - """`--format json` BEFORE the verb name must reach the deferral envelope - too, not Click's exit-2 `cli.parse-error` for an unrecognised pre-command - option. This is the headline behaviour `cli.py`'s `root` callback and - `_HONOURS_ROOT_FORMAT` add for these seven verbs -- verified by hand - against `target/debug/tan.exe` when the feature landed, but until now - pinned by no test, so a revert of either the frozenset or the `ctx.obj` - read stayed green.""" - result = runner.invoke(app, ["--format", "json", verb]) - assert result.exit_code == int(ExitCode.RUNTIME_FAILURE) - envelope = json.loads(result.stdout) - assert envelope["issues"][0]["code"] == DEFERRED_ISSUE_CODE - - -@pytest.mark.parametrize("verb", DEFERRED_VERBS) -def test_deferred_verb_ignores_arbitrary_extra_args(verb): - """A caller's real flags/positionals for the eventual v0.6.0 command must - not turn into a SEPARATE parse error ahead of the deferral message.""" - result = runner.invoke(app, [verb, "--some-future-flag", "value", "positional"]) - assert result.exit_code == int(ExitCode.RUNTIME_FAILURE) - - -def test_the_verb_list_here_matches_the_deferred_module(): - """Guards this test file itself against drifting from `deferred_cmd.py`: - an eighth stub added there (or one removed) must fail THIS test, not just - leave `DEFERRED_VERBS` above stale while every parametrized test still - passes at reduced coverage. So this derives its expectation from the - module's own stub callables instead of hardcoding a third copy of the - verb list -- `stub_names` used to be exactly that third copy.""" - import inspect - - import tan.commands.deferred_cmd as deferred_cmd - - # Every stub `_make_stub` builds carries this exact docstring prefix (see - # `_make_stub`); other module-level callables (`_run_deferred`, - # `_deferred_message`, `_make_stub` itself) do not, so this identifies the - # stub callables actually present without re-listing their names. - stub_marker = "Deferred to v0.6.0, not yet ported to this build" - stub_attrs = { - attr_name - for attr_name, value in vars(deferred_cmd).items() - if inspect.isfunction(value) and (value.__doc__ or "").startswith(stub_marker) - } - assert stub_attrs == {verb.replace("-", "_") for verb in DEFERRED_VERBS} - - -def test_deferred_verbs_all_honour_root_position_format(): - """`cli.py`'s `_HONOURS_ROOT_FORMAT` must list every deferred verb, not - just however many happened to be typed in by hand there -- a regression - check on top of `cli.py` now deriving the set from `DEFERRED_VERBS` - directly (see `_HONOURS_ROOT_FORMAT`'s own comment).""" - assert set(DEFERRED_VERBS) <= _HONOURS_ROOT_FORMAT diff --git a/python/tests/commands/test_diff_command.py b/python/tests/commands/test_diff_command.py new file mode 100644 index 00000000..727716d1 --- /dev/null +++ b/python/tests/commands/test_diff_command.py @@ -0,0 +1,454 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan diff` -- CLI surface tests. + +`diff` is not registered in `tan.cli.app` by this change (the shared +`cli.py` registration point is owned by the orchestrator wiring commands in +parallel), so these tests mount the command on a throwaway `typer.Typer()` +rather than importing `tan.cli.app`, matching +`test_faultdecode_command.py`/`test_kconfig_command.py`'s own note for the +same situation. + +Every wire-shape assertion below (exit code, issue code, `data.unchanged`, +the exact message text for `board-yaml-missing`/the `som:`-shape check) was +measured directly against `target/debug/tan.exe` (tan 0.4.1-dev) -- see the +module docstring in `tan/commands/diff_cmd.py` for the one place this port +knowingly diverges (a non-string `e1m_routes` mapping key: PyYAML raises a +`ConstructorError` at parse time, which this port reports as +`diff.schema-violation`, where the oracle's more permissive `serde_yaml` first +parses it and then fails at the JSON-serialize boundary as +`diff.board-model-not-representable`; both are exit 2). That one case is +intentionally NOT pinned here as a byte-exact oracle match -- it is covered +instead by `test_e1m_routes_non_string_key_is_a_schema_violation`, which +only pins THIS port's own (documented, self-consistent) behaviour. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import typer +from typer.testing import CliRunner + +from tan.commands.diff_cmd import ( + ParseFailure, + _inference_is_empty, + _iot_any_enabled, + _load_document, + _parse_fields, + compute_diff_entries, +) +from tan.commands.diff_cmd import diff as diff_command + +app = typer.Typer() +app.command("diff")(diff_command) + +runner = CliRunner() + +#: `target/{release,debug}/tan(.exe)` next to this checkout -- the same +#: discovery `tests/parity/oracle.py`'s `rust_binary()` uses, kept +#: independent here rather than imported so this file's only non-stdlib +#: dependency stays `tan.commands.diff_cmd` (matching every other test file +#: under `tests/commands/`). `TAN_RUST_BINARY` overrides, same env var. +_EXE = ".exe" if sys.platform == "win32" else "" +_REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _oracle_binary() -> str | None: + override = os.environ.get("TAN_RUST_BINARY") + if override: + return override + for profile in ("release", "debug"): + candidate = _REPO_ROOT / "target" / profile / f"tan{_EXE}" + if candidate.exists(): + return str(candidate) + return None + + +_ORACLE = _oracle_binary() +_ORACLE_REQUIRED = pytest.mark.skipif( + _ORACLE is None, + reason="needs a built Rust tan (cargo build --bin tan) to measure the divergence", +) + + +def _run_oracle(argv: list[str], cwd: Path) -> tuple[int, dict]: + proc = subprocess.run( + [_ORACLE, *argv], capture_output=True, text=True, encoding="utf-8", cwd=cwd + ) + return proc.returncode, json.loads(proc.stdout) + + +def _project(tmp_path: Path, board_yaml_text: str) -> Path: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "board.yaml").write_text(board_yaml_text, encoding="utf-8") + return proj + + +# --------------------------------------------------------------------------- +# CLI surface +# --------------------------------------------------------------------------- + + +def test_help_lists_quiet_and_format() -> None: + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "--quiet" in result.output + assert "--format" in result.output + + +def test_missing_board_yaml_is_a_validation_failure(tmp_path: Path) -> None: + proj = tmp_path / "empty" + proj.mkdir() + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["ok"] is False + assert envelope["project"]["boardYaml"] is None + assert envelope["data"]["boardYamlPath"].endswith("board.yaml") + assert envelope["data"]["unchanged"] is False + assert envelope["issues"] == [ + { + "code": "diff.board-yaml-missing", + "severity": "error", + "message": "board.yaml path could not be resolved or the file does not exist.", + } + ] + + +def test_v2_board_yaml_with_no_stray_fields_is_unchanged(tmp_path: Path) -> None: + proj = _project( + tmp_path, + "som:\n sku: E1M-AEN801\ncores:\n m55_he:\n app: ./src\n", + ) + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["ok"] is True + assert envelope["data"] == { + "schemaVersion": "1", + "boardYamlPath": envelope["data"]["boardYamlPath"], + "unchanged": True, + "changeCount": 0, + "changes": [], + } + assert envelope["issues"] == [] + + +def test_v1_board_yaml_prunes_empty_libraries_iot_inference_sorted_by_path( + tmp_path: Path, +) -> None: + proj = _project( + tmp_path, + "som:\n sku: E1M-AEN701\n" + "libraries: []\n" + "iot:\n wifi: false\n mqtt: false\n" + "inference: {}\n", + ) + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["data"]["changeCount"] == 3 + assert [c["path"] for c in envelope["data"]["changes"]] == ["inference", "iot", "libraries"] + assert envelope["data"]["changes"][0] == { + "path": "inference", + "kind": "removed", + "before": {}, + } + assert envelope["data"]["changes"][1] == { + "path": "iot", + "kind": "removed", + "before": {"wifi": False, "mqtt": False}, + } + assert envelope["data"]["changes"][2] == { + "path": "libraries", + "kind": "removed", + "before": [], + } + + +def test_v2_board_yaml_drops_a_stray_top_level_os(tmp_path: Path) -> None: + proj = _project( + tmp_path, + "schemaVersion: 2\nos: zephyr\nsom:\n sku: E1M-AEN701\n" + "cores:\n m55_he:\n app: ./src\n", + ) + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["data"]["changes"] == [ + {"path": "os", "kind": "removed", "before": "zephyr"} + ] + + +def test_som_scalar_is_a_schema_violation_with_the_oracle_wording(tmp_path: Path) -> None: + proj = _project(tmp_path, "som: E1M-AEN701\n") + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["issues"][0]["code"] == "diff.schema-violation" + assert envelope["issues"][0]["message"] == ( + "board.yaml is not valid: `som:` must be a mapping carrying a `sku:` key, but " + "a scalar was given ('E1M-AEN701'). Write it as:\n som:\n sku: " + ) + + +def test_e1m_routes_non_string_key_is_a_schema_violation(tmp_path: Path) -> None: + """Measured against the oracle: exit 2 both sides. The issue CODE and + exact message diverge (`diff.board-model-not-representable` there, + `diff.schema-violation` here) -- see the module docstring for why PyYAML's + eager `ConstructorError` makes the Rust's later JSON-serialize failure + unreachable through this port. Pinned to this port's own behaviour only. + """ + proj = _project(tmp_path, "e1m_routes:\n usb0:\n ? [d_p, d_n]\n : pads\n") + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["issues"][0]["code"] == "diff.schema-violation" + + +# --------------------------------------------------------------------------- +# Oracle divergences fixed this round (tan-cli diff/pinmux batch) -- byte +# match confirmed against `target/debug/tan.exe` for every case below except +# `test_iot_wrong_type_message_is_a_known_divergence_from_the_oracle`, which +# is the one still-approximate message. +# --------------------------------------------------------------------------- + + +def test_yaml_1_1_only_bool_literal_is_a_string_not_a_type_error(tmp_path: Path) -> None: + """BLOCKER regression: PyYAML's stock `SafeLoader` resolves YAML 1.1's + `on`/`off`/`yes`/`no`/`y`/`n` to `bool`; `_Yaml12BoolLoader` narrows that + to the YAML 1.2 core-schema set (`true`/`True`/`TRUE`/`false`/`False`/ + `FALSE` only), matching `serde_yaml`. Byte-matches the oracle: `os: on` + at `schemaVersion: 1` never even reaches the `os` check (v1 leaves `os` + alone), so the only visible effect here is `libraries: []` still pruning + -- exactly what used to exit 2 `diff.schema-violation` before this fix. + """ + proj = _project(tmp_path, "schemaVersion: 1\nos: on\nlibraries: []\n") + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["ok"] is True + assert envelope["data"]["changes"] == [{"path": "libraries", "kind": "removed", "before": []}] + + +def test_yaml_1_1_only_bool_literal_survives_into_a_v2_os_diff(tmp_path: Path) -> None: + """The same narrowing, exercised on the field it actually guards: at + `schemaVersion: 2`, `os` IS read, and `on` must survive as the string + `"on"` in the emitted diff entry, not a boolean. Byte-matches the oracle. + """ + proj = _project(tmp_path, "schemaVersion: 2\nos: on\nsom:\n sku: E1M-AEN701\n") + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["data"]["changes"] == [{"path": "os", "kind": "removed", "before": "on"}] + + +def test_iot_wrong_type_is_a_schema_violation_not_a_false_accept(tmp_path: Path) -> None: + """`iot: {wifi: "yes"}` used to false-ACCEPT with a FABRICATED `iot` + removal entry: `_typed_field(doc, "iot", dict, ...)` only checked `iot` + itself was a mapping, never that its four toggles were `bool`, so + `_iot_any_enabled`/`_iot_pruned` treated the wrong-typed `wifi` as just + another falsy-but-present value and pruned the whole group. + `_check_iot_field_types` now rejects it before `compute_diff_entries` + ever asks whether the group is prunable.""" + proj = _project(tmp_path, 'schemaVersion: 1\niot:\n wifi: "yes"\n') + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["ok"] is False + assert envelope["data"]["changes"] == [] + assert envelope["issues"][0]["code"] == "diff.schema-violation" + assert envelope["issues"][0]["message"] == ( + "board.yaml is not valid YAML: iot.wifi: expected a boolean, got a string" + ) + + +@_ORACLE_REQUIRED +def test_iot_wrong_type_message_is_a_known_divergence_from_the_oracle(tmp_path: Path) -> None: + """Exit code and issue CODE now match the oracle exactly (see + `test_iot_wrong_type_is_a_schema_violation_not_a_false_accept` for the + behavioural fix). The MESSAGE does not, and is not expected to: + `_typed_nested` reports the same generic `expected X, got Y` shape every + OTHER `_typed_field` check in this module uses (see its module + docstring's scope note -- none of them claims to reproduce `serde_yaml`'s + exact wording), where the oracle's struct-typed deserialize embeds the + offending value and a line/column. Pinned literally on BOTH sides' + message, per this repo's own convention for a deliberate divergence + (`tests/parity/test_oracle_parity.py`'s `..._is_a_known_divergence_from_ + the_oracle` cases) -- a change to either wording, or the two converging, + must fail this test rather than pass it silently. + """ + proj = _project(tmp_path, 'schemaVersion: 1\niot:\n wifi: "yes"\n') + argv = ["--project", str(proj), "--format", "json"] + result = runner.invoke(app, argv) + p_out = json.loads(result.stdout) + r_code, r_out = _run_oracle(["diff", *argv], tmp_path) + + assert result.exit_code == r_code == 2 + assert p_out["issues"][0]["code"] == r_out["issues"][0]["code"] == "diff.schema-violation" + assert r_out["issues"][0]["message"] == ( + 'board.yaml is not valid YAML: iot.wifi: invalid type: string "yes", ' + "expected a boolean at line 3 column 9" + ) + assert p_out["issues"][0]["message"] != r_out["issues"][0]["message"] + # Everything OUTSIDE the message is a real match, not coincidentally + # unchecked -- exit code (asserted above), the issue code (asserted + # above), and `data` (unchanged: false, no changes, same schema version). + assert p_out["data"] == r_out["data"] + + +def test_inference_backend_non_string_scalar_is_not_falsely_pruned(tmp_path: Path) -> None: + """`inference: {backend: 5}` used to false-ACCEPT with a FABRICATED + `inference` removal entry: `_inference_is_empty` defaulted any non-`str` + `backend` to `""` for its emptiness check, treating a present, non-empty + `backend` as blank. The oracle's `backend` is a `String` field that + coerces ANY scalar to non-empty text (`5` -> `"5"`), so it is never + prunable here -- `unchanged: true`, matching the oracle exactly. + """ + proj = _project(tmp_path, "schemaVersion: 1\ninference:\n backend: 5\n") + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["data"]["unchanged"] is True + assert envelope["data"]["changes"] == [] + + +def test_inference_default_arena_kib_wrong_type_is_a_schema_violation(tmp_path: Path) -> None: + """Unlike `backend`, `default_arena_kib` is a real `u32` field: a + non-integer, a bool, or a value outside `[0, u32::MAX]` is a genuine type + mismatch on the oracle, not a leniently-coerced string. Byte-matches the + oracle's exit code and issue code (message approximated, as elsewhere).""" + proj = _project(tmp_path, 'schemaVersion: 1\ninference:\n default_arena_kib: "512"\n') + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["issues"][0]["code"] == "diff.schema-violation" + + +def test_diff_sdk_root_populates_sdk_block_on_success(tmp_path: Path) -> None: + """BLOCKER regression: `--sdk-root` used to be accepted and silently + dropped -- `diff` now resolves it and echoes `sdk.root`/`sdk.sourceTier` + on the success envelope, matching the oracle (byte-matched, including the + `"sdkRootFlag"` source tier spelling `resolve_sdk` already shares with + `pinmux`).""" + sdk = tmp_path / "sdk" + (sdk / "scripts").mkdir(parents=True) + (sdk / "scripts" / "alp_project.py").write_text("", encoding="utf-8") + proj = _project(tmp_path, "som:\n sku: E1M-AEN701\n") + result = runner.invoke( + app, ["--project", str(proj), "--sdk-root", str(sdk), "--format", "json"] + ) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["sdk"] == { + "root": str(sdk).replace("\\", "/"), + "sourceTier": "sdkRootFlag", + } + + +def test_diff_sdk_root_populates_sdk_block_on_board_yaml_missing_failure(tmp_path: Path) -> None: + """The same fix, on the FAILURE envelope -- measured against the oracle: + `diff --sdk-root ` against a missing board.yaml still reports the + `sdk` block on the exit-2 envelope, not just on success.""" + sdk = tmp_path / "sdk" + (sdk / "scripts").mkdir(parents=True) + (sdk / "scripts" / "alp_project.py").write_text("", encoding="utf-8") + empty = tmp_path / "empty" + empty.mkdir() + result = runner.invoke( + app, ["--project", str(empty), "--sdk-root", str(sdk), "--format", "json"] + ) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["sdk"] == { + "root": str(sdk).replace("\\", "/"), + "sourceTier": "sdkRootFlag", + } + assert envelope["issues"][0]["code"] == "diff.board-yaml-missing" + + +def test_text_mode_reports_no_differences(tmp_path: Path) -> None: + proj = _project(tmp_path, "som:\n sku: E1M-AEN701\n") + result = runner.invoke(app, ["--project", str(proj)]) + assert result.exit_code == 0 + assert "diff: no effective-config differences detected." in result.output + + +def test_quiet_suppresses_per_change_lines_but_keeps_the_summary(tmp_path: Path) -> None: + proj = _project(tmp_path, "libraries: []\n") + loud = runner.invoke(app, ["--project", str(proj)]) + quiet = runner.invoke(app, ["--project", str(proj), "--quiet"]) + assert "REMOVED libraries" in loud.output + assert "REMOVED libraries" not in quiet.output + assert "diff: 1 differences in" in quiet.output + + +def test_pyyaml_unavailable_refuses_with_runtime_failure(tmp_path: Path, monkeypatch) -> None: + proj = _project(tmp_path, "som:\n sku: E1M-AEN701\n") + monkeypatch.setitem(sys.modules, "yaml", None) + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 1 + envelope = json.loads(result.stdout) + assert envelope["issues"][0]["code"] == "diff.pyyaml-unavailable" + + +# --------------------------------------------------------------------------- +# Pure-function unit tests (mirrors `crates/tan-core/src/model.rs`'s and +# `diff.rs`'s own `#[cfg(test)]` modules) +# --------------------------------------------------------------------------- + + +def test_iot_any_enabled_requires_an_explicit_true(): + assert _iot_any_enabled({}) is False + assert _iot_any_enabled({"wifi": False, "mqtt": False}) is False + assert _iot_any_enabled({"wifi": True}) is True + + +def test_inference_is_empty_treats_absent_and_blank_backend_alike(): + assert _inference_is_empty({}) is True + assert _inference_is_empty({"backend": ""}) is True + assert _inference_is_empty({"backend": "cpu"}) is False + assert _inference_is_empty({"default_arena_kib": 0}) is False + + +def test_compute_diff_entries_is_removed_only_and_version_gated(): + # v1: os is left alone even when present. + assert compute_diff_entries(1, "zephyr", None, None, None) == [] + # v2: libraries/iot/inference are left alone even when prunable. + assert compute_diff_entries(2, None, [], {}, {}) == [] + # v2 clears a present os. + entries = compute_diff_entries(2, "zephyr", None, None, None) + assert len(entries) == 1 + assert entries[0].path == "os" + assert entries[0].kind == "removed" + + +def test_load_document_wraps_yaml_errors_as_schema_violation(): + try: + _load_document(": : not yaml : :") + except ParseFailure as failure: + assert failure.code == "schema-violation" + assert failure.message.startswith("board.yaml is not valid YAML: ") + else: + raise AssertionError("expected a ParseFailure") + + +def test_parse_fields_rejects_wrong_typed_iot(): + try: + _parse_fields({"iot": "notadict"}) + except ParseFailure as failure: + assert failure.code == "schema-violation" + assert "iot" in failure.message + else: + raise AssertionError("expected a ParseFailure") + + +def test_parse_fields_default_document_is_v1_with_nothing_to_prune(): + assert _parse_fields(None) == (1, None, None, None, None) + assert _parse_fields({}) == (1, None, None, None, None) diff --git a/python/tests/commands/test_doctor_command.py b/python/tests/commands/test_doctor_command.py index 2d0eb110..36c9746f 100644 --- a/python/tests/commands/test_doctor_command.py +++ b/python/tests/commands/test_doctor_command.py @@ -32,7 +32,9 @@ from pathlib import Path import pytest +from typer.testing import CliRunner +from tan.cli import app from tan.commands import doctor_cmd from tan.core.bootstrap import venv_layout, workspace_sdk_record_json @@ -44,6 +46,15 @@ #: can be read without re-typing a repo-relative path in every test. REPO_ROOT = Path(__file__).resolve().parents[3] +#: In-process CLI driver, used ONLY for the `--fix` wiring tests below: they +#: need to monkeypatch `doctor_cmd`'s own module attributes (`can_prompt`, +#: `_collect`, `run_fix`) and observe the effect, which a real `run_tan` +#: subprocess cannot do (the child is a different process, and its stdin/ +#: stderr are captured pipes -- never a tty -- so `can_prompt` is always +#: `False` there regardless of flags; see `test_doctor_fix_interactive_with_ +#: nothing_resolvable_is_a_safe_no_op`'s own history for the same limit). +runner = CliRunner() + def _plant_zephyr_sdk(root: Path) -> None: """Create the one file ``_zephyr_sdk_root_valid`` actually probes, so a @@ -500,10 +511,31 @@ def test_west_resolved_reproduces_and_closes_tan_cli_123(tmp_path): # every DLL that sits beside the real interpreter alongside the # renamed copy closes that gap; `--version` then runs the copied # interpreter's own, always-parseable banner. - interpreter_dir = Path(sys.executable).parent + # + # tan-cli#297: the source must be the BASE interpreter + # (`sys.base_prefix`), never `sys.executable` as such. When pytest + # itself runs from a project venv (`.venv\Scripts\python.exe`), + # `sys.executable` names a launcher stub that ships with NO sibling + # DLLs at all (they stay in the base install) and needs its own + # `pyvenv.cfg` to find them -- reproduced directly: copying that stub + # elsewhere and running `--version` fails outright with "No pyvenv.cfg + # file" (exit 106), never a parseable banner, which is exactly the + # "west.exe version-banner assertion" failure this closes. Reading + # `sys.base_prefix` instead is a no-op when pytest already runs from a + # base install (this host: `sys.executable == sys.base_prefix`), and + # resolves to the same self-contained layout either way. + interpreter_dir = Path(sys.base_prefix) + base_python = interpreter_dir / "python.exe" + if not base_python.is_file(): + # A base layout with no `python.exe` (e.g. an embeddable/portable + # install with a differently-named executable) turns this fixture + # into a hard `FileNotFoundError` rather than a clean skip -- this + # is a fixture-construction gap, not something the test is meant + # to catch. + pytest.skip(f"no base interpreter at {base_python} to build the self-contained west.exe fixture from") for dll in interpreter_dir.glob("*.dll"): shutil.copy(dll, bin_dir / dll.name) - shutil.copy(sys.executable, west_path) + shutil.copy(base_python, west_path) else: west_path.write_text("#!/bin/sh\necho 'West version: v99.98.97'\n", encoding="utf-8") os.chmod(west_path, 0o755) @@ -1686,6 +1718,117 @@ def test_collect_names_no_unselected_candidate_when_discovery_itself_answered(tm assert "was not selected" not in check.detail +# -------------------------------------------------------------------------- +# tan-cli#344 -- a dangling `~/.alp/sdk-default` is a distinct fact from +# "nothing configured": falling through stays correct, exit 4 stays correct, +# only the `sdk` check's remedy text changes. +# -------------------------------------------------------------------------- + + +def test_broken_global_default_is_none_when_nothing_is_configured(): + assert doctor_cmd._broken_global_default() is None + + +def test_broken_global_default_is_none_when_the_pointer_resolves(tmp_path): + target = _make_sdk_root(tmp_path / "alp-sdk") + _write_global_default_pointer(target) + assert doctor_cmd._broken_global_default() is None + + +def test_broken_global_default_names_the_dangling_target(tmp_path): + broken_target = tmp_path / "gone" + _write_global_default_pointer(broken_target) + assert doctor_cmd._broken_global_default() == str(broken_target) + + +def test_sdk_check_names_a_broken_global_default_distinctly_from_nothing_configured( + tmp_path, +): + """The exact tan-cli#344 defect: before this, both cases printed the + identical `NO_SDK_NEXT_STEPS` sentence. The remedy must name the pointer + path, offer to delete or hand-edit it (never `tan sdk switch`, which + refuses outright per tan-cli#305), and still offer `--sdk-root`.""" + broken_target = tmp_path / "gone" + + nothing_configured = doctor_cmd.sdk_check(None, project_scope=None) + broken_default = doctor_cmd.sdk_check( + None, project_scope=None, broken_global_default=str(broken_target) + ) + + assert nothing_configured.status == broken_default.status == "fail" + assert nothing_configured.detail != broken_default.detail + + assert str(broken_target) in broken_default.detail + assert str(broken_target) not in nothing_configured.detail + + # tan-cli#305: never recommend the refused `sdk switch` subcommand. + assert "sdk switch" not in broken_default.detail + assert "sdk switch" not in broken_default.fix + assert "delete" in broken_default.fix + assert "--sdk-root" in broken_default.fix + + # The plain "nothing configured" sentence is untouched. + assert "get an alp-sdk checkout" in nothing_configured.detail + + +def test_sdk_check_ignores_broken_global_default_once_something_else_resolves(): + """`broken_global_default` only matters in the `sdk_root is None` branch + -- a resolved SDK's `pass` detail must not change shape just because a + stale default also happens to be lying around.""" + check = doctor_cmd.sdk_check( + "/opt/alp-sdk", project_scope=None, broken_global_default="/gone" + ) + assert check.status == "pass" + assert check.detail == "alp-sdk at /opt/alp-sdk" + + +def test_collect_names_a_broken_global_default_end_to_end(tmp_path): + """tan-cli#344 through the whole pipeline: `resolve_sdk_root_ladder` + falls through the broken pointer to `none` (UNCHANGED behaviour), while + `_collect`'s `sdk` check now says why.""" + workspace = tmp_path / "ws" + workspace.mkdir() + broken_target = tmp_path / "gone" + _write_global_default_pointer(broken_target) + + resolved_root, tier, broken_pin = doctor_cmd.resolve_sdk_root_ladder(None, workspace) + assert resolved_root is None + assert tier == "none" + assert broken_pin is None # this is the GLOBAL default, not the project pin + + checks = doctor_cmd._collect( + None, + workspace_root=str(workspace), + sdk_tier=tier, + broken_global_default=doctor_cmd._broken_global_default(), + ) + sdk = next(c for c in checks if c.name == "sdk") + assert sdk.status == "fail" + assert str(broken_target) in sdk.detail + + +def test_doctor_names_a_broken_global_default_end_to_end_via_the_cli(tmp_path): + """Real subprocess, real envelope: the exit code (4) and the fall-through + (no SDK selected) are both unchanged from before tan-cli#344; only the + `sdk` check's `detail`/`fix` differ.""" + broken_target = tmp_path / "gone" + # `_write_global_default_pointer`, not a second hand-rolled copy of the + # same three lines -- this file already owns one (used above by + # `test_collect_names_a_broken_global_default_end_to_end` and friends). + _write_global_default_pointer(broken_target) + workspace = tmp_path / "ws" + workspace.mkdir() + + proc = run_tan("doctor", "--format", "json", cwd=workspace) + envelope = json.loads(proc.stdout) + assert envelope["exitCode"] == 4 + sdk = next(c for c in envelope["data"]["checks"] if c["name"] == "sdk") + assert sdk["status"] == "fail" + assert str(broken_target) in sdk["detail"] + assert "sdk switch" not in sdk["fix"] + assert "--sdk-root" in sdk["fix"] + + def test_board_yaml_preflight_check_passes_when_present_regardless_of_selection(): assert doctor_cmd.board_yaml_preflight_check(True, project_selected=False).status == "pass" assert doctor_cmd.board_yaml_preflight_check(True, project_selected=True).status == "pass" @@ -2142,3 +2285,423 @@ def test_collect_reports_sdk_provenance_only_when_an_sdk_resolves(tmp_path): with_sdk = doctor_cmd._collect(str(tmp_path), workspace_root=str(tmp_path)) assert "sdkProvenance" in {c.name for c in with_sdk} + + +# -------------------------------------------------------------------------- +# tan-cli#91 / ADR 0021 -- `doctor --fix` runs the manifest's own install +# commands for a missing `hostPrerequisites` tool, MAINTAINER DECISION: +# REFUSE AND PRINT anything needing `sudo`, never spawn it. `run_fix` is fed +# exactly `hostPrerequisites`'s own `Check.missing` -- never a second, +# independently recomputed tool/command list. +# -------------------------------------------------------------------------- + + +def test_fix_needs_sudo_check_names_the_command_verbatim_and_never_hints_at_running_it(): + check = doctor_cmd.fix_needs_sudo_check("git", "sudo apt-get install -y git") + assert check.status == "warn" + assert check.code == "doctor.fix-needs-sudo" + assert "sudo apt-get install -y git" in check.detail + assert check.fix == "sudo apt-get install -y git" + + +def test_fix_installed_check_never_claims_the_tool_is_now_on_path(): + check = doctor_cmd.fix_installed_check("ninja", "winget install -e --id Ninja-build.Ninja") + assert check.status == "warn" + assert check.code == "doctor.fix-installed" + assert "winget install -e --id Ninja-build.Ninja" in check.detail + # tan-cli#91: no same-process re-check -- the honest outcome is "reopen + # your shell", never a claimed-verified pass. + assert "reopen" in check.detail or "new shell" in check.detail + + +def test_run_fix_refuses_a_sudo_command_and_never_spawns_it(monkeypatch): + def _must_not_run(*_args, **_kwargs): + raise AssertionError("run_fix must never spawn a command needing sudo") + + monkeypatch.setattr(doctor_cmd.subprocess, "run", _must_not_run) + monkeypatch.setattr(doctor_cmd, "on_path", _must_not_run) + + results = doctor_cmd.run_fix( + [{"tool": "git", "command": "sudo apt-get install -y git"}] + ) + assert len(results) == 1 + assert results[0].code == "doctor.fix-needs-sudo" + + +def test_run_fix_runs_a_no_elevation_command_through_the_resolved_binary(monkeypatch, tmp_path): + fake_exe = tmp_path / "winget.exe" + fake_exe.write_text("", encoding="utf-8") + monkeypatch.setattr( + doctor_cmd, "on_path", lambda name: str(fake_exe) if name == "winget" else None + ) + captured = {} + + def _fake_run(argv, **kwargs): + captured["argv"] = argv + return subprocess.CompletedProcess(argv, 0) + + monkeypatch.setattr(doctor_cmd.subprocess, "run", _fake_run) + + results = doctor_cmd.run_fix( + [{"tool": "ninja", "command": "winget install -e --id Ninja-build.Ninja"}] + ) + assert len(results) == 1 + assert results[0].code == "doctor.fix-installed" + # Resolved through `on_path`, never the bare tool name -- the same + # PATH-only resolver every other spawn in this module goes through. + assert captured["argv"][0] == str(fake_exe) + assert captured["argv"][1:] == ["install", "-e", "--id", "Ninja-build.Ninja"] + + +def test_run_fix_skips_a_tool_with_no_known_install_command(): + assert doctor_cmd.run_fix([{"tool": "gperf", "command": None}]) == [] + + +def test_run_fix_reports_a_verdict_when_the_installer_itself_is_not_on_path(monkeypatch): + """tan-cli#360, replacing the test that pinned the silence + (`assert results == []`): an unresolved installer used to be a bare + `continue` -- the ONE outcome `run_fix` emitted no Check for, in a + function whose whole stated invariant is that every entry is run or + refused and every outcome becomes a check. + + Reachable on exactly the hosts `--fix` exists for: the manifest installs + with `winget` on Windows and `brew` on macOS, so a machine without one + reported tools missing, ACCEPTED `--fix`, and printed the same report + back with no `fix:*` line anywhere -- indistinguishable from "nothing + needed fixing".""" + monkeypatch.setattr(doctor_cmd, "on_path", lambda _name: None) + results = doctor_cmd.run_fix( + [{"tool": "ninja", "command": "winget install -e --id Ninja-build.Ninja"}] + ) + assert len(results) == 1 + assert results[0].code == "doctor.fix-installer-not-found" + assert results[0].status == "warn" + # The installer AND the tool it blocked: naming only one of the two leaves + # the reader unable to act on it. + assert "winget" in results[0].detail + assert "ninja" in results[0].detail + # The remedy, not just "not found" -- on Windows that is App Installer. + assert "App Installer" in results[0].detail + + +def test_run_fix_reports_one_missing_installer_once_not_once_per_tool(monkeypatch): + """tan-cli#360 acceptance: a fresh Mac is missing every manifest + prerequisite at once and every one of them installs with `brew`. Per-tool + reporting would print the same "install Homebrew" paragraph six times -- + six restatements of one fact, in a report someone is reading to find out + what is actually wrong.""" + monkeypatch.setattr(doctor_cmd, "on_path", lambda _name: None) + results = doctor_cmd.run_fix( + [ + {"tool": "git", "command": "brew install git"}, + {"tool": "cmake", "command": "brew install cmake"}, + {"tool": "ninja", "command": "brew install ninja"}, + ] + ) + assert len(results) == 1 + assert results[0].name == "fix:brew" + # One verdict, every tool that one absence blocked named inside it. + for tool in ("git", "cmake", "ninja"): + assert tool in results[0].detail + assert results[0].detail.count("brew.sh") == 1 + + +def test_run_fix_groups_per_installer_and_still_runs_the_ones_it_can_resolve( + monkeypatch, tmp_path +): + """Two distinct absent installers are two distinct verdicts -- grouping + must not collapse unrelated remedies into one -- and a tool whose + installer DOES resolve is still repaired in the same pass, so the + grouping cannot swallow the working path.""" + fake_exe = tmp_path / "winget.exe" + fake_exe.write_text("", encoding="utf-8") + monkeypatch.setattr( + doctor_cmd, "on_path", lambda name: str(fake_exe) if name == "winget" else None + ) + monkeypatch.setattr( + doctor_cmd.subprocess, + "run", + lambda argv, **k: subprocess.CompletedProcess(argv, 0), + ) + results = doctor_cmd.run_fix( + [ + {"tool": "ninja", "command": "winget install -e --id Ninja-build.Ninja"}, + {"tool": "git", "command": "brew install git"}, + {"tool": "cmake", "command": "pixi global install cmake"}, + ] + ) + assert [c.code for c in results] == [ + "doctor.fix-installed", + "doctor.fix-installer-not-found", + "doctor.fix-installer-not-found", + ] + assert [c.name for c in results[1:]] == ["fix:brew", "fix:pixi"] + # An installer this module has never heard of still gets a remedy that + # names it, rather than a bare "not found". + assert "Install `pixi`" in results[2].detail + + +def test_doctor_fix_explains_a_missing_installer_in_both_text_and_json(monkeypatch, tmp_path): + """tan-cli#360 acceptance: BOTH output modes must say that no repair ran. + Driven through the real command rather than `run_fix` directly, because + the two modes render from different places -- JSON from `data.checks` + + `issues`, text from a `print` loop over `data.checks` to stderr -- and a + verdict reaching only one of them is the same bug wearing a different + hat. `can_prompt` is stubbed for the reason the section header above + gives: `CliRunner`'s pipes are never a tty.""" + missing = [{"tool": "ninja", "command": "brew install ninja"}] + stub_checks = [doctor_cmd.Check("hostPrerequisites", "fail", "ninja missing", missing=missing)] + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: stub_checks) + monkeypatch.setattr(doctor_cmd, "can_prompt", lambda **k: True) + monkeypatch.setattr(doctor_cmd, "on_path", lambda _name: None) + monkeypatch.chdir(tmp_path) + + as_json = json.loads(runner.invoke(app, ["doctor", "--fix", "--format", "json"]).output) + assert "doctor.fix-installer-not-found" in {i["code"] for i in as_json["issues"]} + json_detail = next( + c["detail"] for c in as_json["data"]["checks"] if c["name"] == "fix:brew" + ) + + # Text mode prints every check to stderr, envelope-free. + text_detail = runner.invoke(app, ["doctor", "--fix"]).stderr + + for detail in (json_detail, text_detail): + assert "ran no repair" in detail + assert "brew" in detail + assert "ninja" in detail + + +def test_run_fix_reports_a_check_when_the_install_command_exits_non_zero(monkeypatch, tmp_path): + """A customer who typed `--fix` and watched it do nothing must be able to + tell "tan tried and the install itself failed" from "tan never tried" -- + the exact silence tan-cli#91's own review flagged (a bare `continue` + here). `hostPrerequisites` still names the tool separately; this Check + is the only place the FAILED ATTEMPT itself is reported.""" + fake_exe = tmp_path / "winget.exe" + fake_exe.write_text("", encoding="utf-8") + monkeypatch.setattr(doctor_cmd, "on_path", lambda _name: str(fake_exe)) + monkeypatch.setattr( + doctor_cmd.subprocess, + "run", + lambda argv, **k: subprocess.CompletedProcess(argv, 1), + ) + results = doctor_cmd.run_fix( + [{"tool": "ninja", "command": "winget install -e --id Ninja-build.Ninja"}] + ) + assert len(results) == 1 + assert results[0].code == "doctor.fix-failed" + assert results[0].name == "fix:ninja" + assert "1" in results[0].detail + + +def test_run_fix_reports_a_check_when_the_spawn_itself_raises(monkeypatch, tmp_path): + fake_exe = tmp_path / "winget.exe" + fake_exe.write_text("", encoding="utf-8") + monkeypatch.setattr(doctor_cmd, "on_path", lambda _name: str(fake_exe)) + + def _raise(*_a, **_k): + raise OSError("no such file or directory") + + monkeypatch.setattr(doctor_cmd.subprocess, "run", _raise) + results = doctor_cmd.run_fix( + [{"tool": "ninja", "command": "winget install -e --id Ninja-build.Ninja"}] + ) + assert len(results) == 1 + assert results[0].code == "doctor.fix-spawn-failed" + assert "no such file or directory" in results[0].detail + + +def test_run_fix_reports_a_check_when_the_install_command_times_out(monkeypatch, tmp_path): + fake_exe = tmp_path / "winget.exe" + fake_exe.write_text("", encoding="utf-8") + monkeypatch.setattr(doctor_cmd, "on_path", lambda _name: str(fake_exe)) + + def _timeout(argv, **k): + raise subprocess.TimeoutExpired(argv, k.get("timeout", doctor_cmd.FIX_INSTALL_TIMEOUT_S)) + + monkeypatch.setattr(doctor_cmd.subprocess, "run", _timeout) + results = doctor_cmd.run_fix( + [{"tool": "ninja", "command": "winget install -e --id Ninja-build.Ninja"}] + ) + assert len(results) == 1 + assert results[0].code == "doctor.fix-timed-out" + assert str(doctor_cmd.FIX_INSTALL_TIMEOUT_S) in results[0].detail + + +def test_doctor_fix_is_disabled_under_ci_non_interactive_and_json(tmp_path): + """tan-cli#91: `--fix` must never actually attempt a repair under + `--ci`, `--non-interactive`, or `--format json` -- the idiom this + codebase already applies to any command that would otherwise mutate the + host rather than merely report on it.""" + for extra in (["--ci"], ["--non-interactive"], []): + proc = run_tan( + "doctor", "--fix", "--format", "json", *extra, cwd=tmp_path, scrub_path=True + ) + envelope = json.loads(proc.stdout) + names = {c["name"] for c in envelope["data"]["checks"]} + assert not any(n.startswith("fix:") for n in names), (extra, names) + assert not any( + i["code"] in ("doctor.fix-needs-sudo", "doctor.fix-installed") + for i in envelope["issues"] + ), (extra, envelope["issues"]) + + +def test_doctor_fix_interactive_with_nothing_resolvable_is_a_safe_no_op(tmp_path): + """PATH scrubbed -- every `hostPrerequisites` tool is missing AND + unresolvable via `on_path`, so an interactive `--fix` (the guard is + satisfied: no `--ci`, no `--non-interactive`, text mode) reaches + `run_fix` and finds nothing it can actually run. The report must stay + well-formed and the exit code unchanged (still 4 -- `hostPrerequisites` + is still failing, `--fix` ran and genuinely fixed nothing).""" + proc = run_tan("doctor", "--fix", cwd=tmp_path, scrub_path=True) + assert "Traceback" not in proc.stderr + assert proc.returncode == 4 + + +# -------------------------------------------------------------------------- +# The `--fix` WIRING itself. `test_doctor_fix_is_disabled_under_ci_non_ +# interactive_and_json` above passes `--format json` in EVERY loop +# iteration, so `json_mode` alone already satisfies every one of its +# assertions regardless of `--ci`/`--non-interactive` -- it cannot tell a +# correct guard from `if fix and not json_mode` (ignores `--ci`/ +# `--non-interactive` entirely) or `if fix or True` (guard deleted, `--fix` +# ignored). And no `run_tan` subprocess test can ever grant consent at all: +# `can_prompt`'s two `isatty()` checks read `False` off a captured pipe every +# time, flags aside -- see `tan.core.consent`. In-process, via `CliRunner` +# and monkeypatching `doctor_cmd`'s own module attributes, is the only way to +# drive BOTH the consent-granted path and the guard's flag logic without +# spawning a real install. +# -------------------------------------------------------------------------- + + +def test_doctor_fix_invokes_run_fix_and_folds_its_checks_into_the_report_when_consent_is_granted( + monkeypatch, tmp_path +): + """The positive case: with consent genuinely granted, `run_fix` must + actually be called with `hostPrerequisites`'s OWN `missing` list, and its + resulting Checks must reach the report -- not just "the guard didn't + crash". Fails red against `checks = [*checks, *run_fix(missing_for_fix)]` + replaced by `pass` (the feature unwired entirely): `run_fix` would never + be called and its Check would never reach `data.checks`/`issues`.""" + missing = [{"tool": "ninja", "command": "winget install -e --id Ninja-build.Ninja"}] + stub_checks = [doctor_cmd.Check("hostPrerequisites", "fail", "ninja missing", missing=missing)] + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: stub_checks) + monkeypatch.setattr(doctor_cmd, "can_prompt", lambda **k: True) + + calls = [] + + def _spy_run_fix(missing_arg): + calls.append(missing_arg) + return [doctor_cmd.fix_installed_check("ninja", missing[0]["command"])] + + monkeypatch.setattr(doctor_cmd, "run_fix", _spy_run_fix) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["doctor", "--fix", "--format", "json"]) + assert calls == [missing], calls + envelope = json.loads(result.output) + assert envelope["exitCode"] == 4 # hostPrerequisites is still a Fail + names = [c["name"] for c in envelope["data"]["checks"]] + assert "fix:ninja" in names, names + codes = [i["code"] for i in envelope["issues"]] + assert "doctor.fix-installed" in codes, codes + + +def test_doctor_fix_guard_honours_ci_even_in_text_mode(monkeypatch, tmp_path): + """The negative case, through the REAL (unmonkeypatched) `can_prompt`: + `--fix --ci` in TEXT mode (no `--format json`) must never call `run_fix`. + Fails red against `if fix and not json_mode` (`--ci` plays no part in + that condition, and text mode makes `not json_mode` true) and against + `if fix or True` (guard deleted, always runs).""" + missing = [{"tool": "ninja", "command": "winget install -e --id Ninja-build.Ninja"}] + stub_checks = [doctor_cmd.Check("hostPrerequisites", "fail", "ninja missing", missing=missing)] + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: stub_checks) + + calls = [] + monkeypatch.setattr(doctor_cmd, "run_fix", lambda m: calls.append(m) or []) + monkeypatch.chdir(tmp_path) + + runner.invoke(app, ["doctor", "--fix", "--ci"]) + assert calls == [], calls + + +def test_doctor_fix_guard_honours_no_tty_the_same_way_ci_does(monkeypatch, tmp_path): + """Same shape as the `--ci` case above, but for the "unasked" half of + `can_prompt` (`tan.core.consent`): even with none of `--ci`/ + `--non-interactive`/`--format json` passed, a non-terminal stdin/stderr + (exactly what `CliRunner`/any captured-pipe run provides, and exactly + what tan-cli#91's own postmortem measured -- a CI runner that redirected + output but never passed `--ci`) must refuse `--fix` the same way `--ci` + does.""" + missing = [{"tool": "ninja", "command": "winget install -e --id Ninja-build.Ninja"}] + stub_checks = [doctor_cmd.Check("hostPrerequisites", "fail", "ninja missing", missing=missing)] + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: stub_checks) + + calls = [] + monkeypatch.setattr(doctor_cmd, "run_fix", lambda m: calls.append(m) or []) + monkeypatch.chdir(tmp_path) + + # No --ci, no --non-interactive, no --format json -- only `can_prompt`'s + # own isatty() reads (both False under CliRunner) can be refusing this. + runner.invoke(app, ["doctor", "--fix"]) + assert calls == [], calls + + +# -------------------------------------------------------------------------- +# tan-cli#91 P1: `--fix` suppressed must SAY SO, not silently reproduce +# plain `tan doctor`'s report -- the oracle divergence on `doctor --fix +# --format json` (oracle: exitCode 2, cli.parse-error; this port: used to be +# byte-for-byte identical to plain `tan doctor`, no issue, no note). +# -------------------------------------------------------------------------- + + +def test_fix_suppressed_issue_names_every_condition_that_tripped(): + issue = doctor_cmd.fix_suppressed_issue(non_interactive=False, ci=True, json_mode=True) + assert issue.code == "doctor.fix-suppressed" + assert issue.severity == "warning" + assert "--ci" in issue.message + assert "--format json" in issue.message + + +def test_fix_suppressed_issue_never_reads_isatty_under_json_mode(monkeypatch): + """`tan.cli.main` tees `sys.stderr` through `_TeeStderr` under + `--format json`, which has no `isatty()` at all -- reading it + unconditionally here is the exact `AttributeError` measured against a + real `tan doctor --fix --format json --ci` run. `json_mode=True` must + short-circuit past both `isatty()` reads, mirroring `can_prompt`'s own + order, not merely happen to avoid them under THIS monkeypatch.""" + + class _NoIsatty: + def isatty(self): + raise AttributeError("'_TeeStderr' object has no attribute 'isatty'") + + monkeypatch.setattr(doctor_cmd.sys, "stdin", _NoIsatty()) + monkeypatch.setattr(doctor_cmd.sys, "stderr", _NoIsatty()) + issue = doctor_cmd.fix_suppressed_issue(non_interactive=False, ci=False, json_mode=True) + assert issue.code == "doctor.fix-suppressed" + assert "--format json" in issue.message + + +def test_doctor_fix_format_json_is_no_longer_a_silent_no_op(monkeypatch, tmp_path): + """The exact reported shape: `doctor --fix --format json` on an + unhealthy host used to be byte-for-byte identical to plain `tan doctor`. + Now it must carry a `doctor.fix-suppressed` issue naming why, even though + `run_fix` itself is never called.""" + missing = [{"tool": "ninja", "command": "winget install -e --id Ninja-build.Ninja"}] + stub_checks = [doctor_cmd.Check("hostPrerequisites", "fail", "ninja missing", missing=missing)] + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: stub_checks) + calls = [] + monkeypatch.setattr(doctor_cmd, "run_fix", lambda m: calls.append(m) or []) + monkeypatch.chdir(tmp_path) + + plain = runner.invoke(app, ["doctor", "--format", "json"]) + fixed = runner.invoke(app, ["doctor", "--fix", "--format", "json"]) + assert calls == [], calls + + plain_envelope = json.loads(plain.output) + fixed_envelope = json.loads(fixed.output) + assert fixed_envelope["exitCode"] == plain_envelope["exitCode"] == 4 + assert not any(i["code"] == "doctor.fix-suppressed" for i in plain_envelope["issues"]) + suppressed = [i for i in fixed_envelope["issues"] if i["code"] == "doctor.fix-suppressed"] + assert len(suppressed) == 1, fixed_envelope["issues"] + assert "--format json" in suppressed[0]["message"] diff --git a/python/tests/commands/test_execute.py b/python/tests/commands/test_execute.py index 7c335cad..498a387b 100644 --- a/python/tests/commands/test_execute.py +++ b/python/tests/commands/test_execute.py @@ -93,8 +93,11 @@ def _plan(command: str, backend: str = "zephyr") -> str: def test_successful_slice_reports_succeeded(tmp_path): + # backend "baremetal", not the `_plan()` default "zephyr": this is a bare + # dispatch-succeeds check, no Zephyr boilerplate on disk -- a `zephyr` + # backend here would trip the tan-cli#309 guard and report "failed". cmd = f'{{"tool": {PYTHON}, "args": ["-c", "print(1)"], "cwd": null}}' - out = execute_slices(parse_build_plan(_plan(cmd)), build_root=tmp_path, + out = execute_slices(parse_build_plan(_plan(cmd, backend="baremetal")), build_root=tmp_path, env_lookup=lambda k: None, gap_fillers=[], on_output=lambda s: None) assert out[0].status == "succeeded" assert out[0].exit_code == 0 @@ -222,7 +225,9 @@ def test_undecodable_stdout_bytes_are_replaced_not_fatal(tmp_path): ) cmd = f'{{"tool": {PYTHON}, "args": ["-c", {json.dumps(script)}], "cwd": null}}' lines = [] - out = execute_slices(parse_build_plan(_plan(cmd)), build_root=tmp_path, + # backend "baremetal": this is a stdout-decoding concern, unrelated to + # Zephyr -- see the tan-cli#309 comment on the test above. + out = execute_slices(parse_build_plan(_plan(cmd, backend="baremetal")), build_root=tmp_path, env_lookup=lambda k: None, gap_fillers=[], on_output=lines.append) assert out[0].status == "succeeded" assert lines, "expected at least one output line" @@ -391,7 +396,10 @@ def test_mismatched_sdk_stamp_wipes_and_restamps_the_build_dir(tmp_path, monkeyp _configure(slice_dir, "/sdk/v0.11.0") cmd = f'{{"tool": {PYTHON}, "args": ["-c", "pass"], "cwd": "build/c1"}}' lines = [] - out = execute_slices(parse_build_plan(_plan(cmd)), build_root=tmp_path, + # backend "baremetal": this test is about the sdk-switch-pristine wipe, + # orthogonal to Zephyr -- the `pass` command leaves no Zephyr boilerplate + # behind, which would otherwise trip the tan-cli#309 guard. + out = execute_slices(parse_build_plan(_plan(cmd, backend="baremetal")), build_root=tmp_path, env_lookup=lambda k: None, gap_fillers=[], on_output=lines.append, sdk_root="/sdk/v0.13.0") assert out[0].status == "succeeded" @@ -602,23 +610,28 @@ def test_manifest_overlay_writes_ok_and_failed_status_for_the_right_slices( fixture_manifest = ( "schema_version: 1\nhw_info:\n sku: S\nslices:\n" - "- core_id: ok_core\n os: zephyr\n status: pending\n" - "- core_id: bad_core\n os: zephyr\n status: pending\n" + "- core_id: ok_core\n os: baremetal\n status: pending\n" + "- core_id: bad_core\n os: baremetal\n status: pending\n" "ipc: []\nhelper_mcus: []\nboot_order: []\n" ) monkeypatch.setattr(planner_root, "emit", lambda *a, **k: fixture_manifest) + # backend "baremetal" on both slices, not "zephyr": this test is about + # `status` -> manifest wiring, not Zephyr artefact evidence -- a "zephyr" + # backend here would trip the tan-cli#309 guard and force ok_core's real + # exit-0 "pass" to read back as "failed" too, breaking the very + # succeeded-vs-failed distinction this test exists to pin. plan_json = f"""{{ "schemaVersion": 1, "generatedBy": "g", "boardYaml": {json.dumps(str(tmp_path / "board.yaml"))}, "sku": "S", "buildRoot": "build", "sharedArtefacts": [], "warnings": [], "executionPolicy": {{"missingTool": "skip", "nullCommand": "skip", "unknownBackend": "fail"}}, "slices": [ - {{"coreId": "ok_core", "backend": "zephyr", "buildDir": "build/ok_core", "appDir": "app", + {{"coreId": "ok_core", "backend": "baremetal", "buildDir": "build/ok_core", "appDir": "app", "configArtefacts": [], "toolchain": null, "artifacts": [], "debug": {{}}, "command": {{"tool": {PYTHON}, "args": ["-c", "pass"], "cwd": null}}, "env": {{}}, "envAppendPath": {{}}}}, - {{"coreId": "bad_core", "backend": "zephyr", "buildDir": "build/bad_core", "appDir": "app", + {{"coreId": "bad_core", "backend": "baremetal", "buildDir": "build/bad_core", "appDir": "app", "configArtefacts": [], "toolchain": null, "artifacts": [], "debug": {{}}, "command": {{"tool": {PYTHON}, "args": ["-c", "raise SystemExit(1)"], "cwd": null}}, "env": {{}}, "envAppendPath": {{}}}} @@ -897,8 +910,14 @@ def test_west_build_pins_the_resolved_workspace_over_an_ancestor_west(tmp_path, argv_probe = real_ws / "argv.txt" script = ( f"import os, sys\n" + f"args = sys.argv[1:]\n" f"open({json.dumps(str(probe))}, 'w').write(os.getcwd())\n" - f"open({json.dumps(str(argv_probe))}, 'w').write(repr(sys.argv[1:]))\n" + f"open({json.dumps(str(argv_probe))}, 'w').write(repr(args))\n" + # tan-cli#309: a real `west build` leaves Zephyr's own `zephyr/` + # output dir under the resolved `-d` build dir -- without this the + # tan-cli#309 guard (no evidence Zephyr's CMake boilerplate ran) + # would force this slice "failed" even though the stand-in exits 0. + f"os.makedirs(os.path.join(args[args.index('-d') + 1], 'zephyr'), exist_ok=True)\n" ) (real_ws / "build").write_text(script, encoding="utf-8") @@ -959,9 +978,29 @@ def _fake_west_build_script() -> str: when that doesn't lead to a workspace either. `sys.argv[-1]` is the slice's source dir: with no trailing cmake `--` options in the test plans below, `_pin_west_workspace`'s rewritten args always end with it. + + On each success path it also writes the `build/CMakeCache.txt` + `ZEPHYR_BASE:` entry a REAL successful `west build` leaves behind, which + tan-cli#309's `zephyr_boilerplate_loaded` guard reads as its evidence + that Zephyr's CMake boilerplate actually ran. Without it this shim exits + 0 having produced nothing, and #309 correctly fails the slice -- masking + what #336's own assertions are about. The two fixes are orthogonal; the + fixture has to satisfy both for either to be measurable. """ return ( "import os, sys\n" + # The build dir is `-d`'s value when present, NOT `/build`: + # tan-cli#307 pins the child's cwd to the WORKSPACE and injects an + # explicit `-d /build` to keep the output where it would + # otherwise have defaulted. A shim writing to `/build` would + # write into the workspace -- which here is where this very script + # lives, so `makedirs` raises FileExistsError against the file. + "def ok():\n" + " d = sys.argv[sys.argv.index('-d') + 1] if '-d' in sys.argv else os.path.join(os.getcwd(), 'build')\n" + " os.makedirs(d, exist_ok=True)\n" + " with open(os.path.join(d, 'CMakeCache.txt'), 'w') as fh:\n" + " fh.write('ZEPHYR_BASE:PATH=/fake/zephyr\\n')\n" + " sys.exit(0)\n" "def has_dot_west(p):\n" " while True:\n" " if os.path.isdir(os.path.join(p, '.west')):\n" @@ -972,10 +1011,10 @@ def _fake_west_build_script() -> str: " p = parent\n" "source_dir = sys.argv[-1]\n" "if has_dot_west(source_dir):\n" - " sys.exit(0)\n" + " ok()\n" "zb = os.environ.get('ZEPHYR_BASE') or os.path.join(os.getcwd(), 'zephyr')\n" "if has_dot_west(os.path.dirname(zb)):\n" - " sys.exit(0)\n" + " ok()\n" "print('FATAL ERROR: Could not find a west workspace in this or any parent directory')\n" "sys.exit(1)\n" ) diff --git a/python/tests/commands/test_execute_zephyr_env.py b/python/tests/commands/test_execute_zephyr_env.py new file mode 100644 index 00000000..c0b6e590 --- /dev/null +++ b/python/tests/commands/test_execute_zephyr_env.py @@ -0,0 +1,320 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#308 end to end: `execute_slices` fills `ZEPHYR_BASE`/ +`EXTRA_ZEPHYR_MODULES` for the spawned CMake/west child from the west +workspace it resolves itself (`tan.core.zephyr_env.zephyr_env_overrides`), +the same way `test_execute.py`'s own tan-cli#307 +`test_west_build_pins_the_resolved_workspace_over_an_ancestor_west` proves +the workspace-pin wiring -- a manifest-verified `.west/config` naming the +fake `sdk_root`, not a bare directory, so `west_workspace_dir` actually +resolves it rather than silently no-op'ing to `None` (the pre-fix state, +which this suite's own `test_...` below reproduces to prove the fail-before/ +pass-after ordering). + +Slices here are declared `backend: baremetal`, not `zephyr`: the gap-filler +[`zephyr_env_overrides`] itself has no backend check (neither does the Rust +oracle's own call site, `execute/mod.rs`, inside its per-slice loop with no +guard before it) -- it is applied to every slice regardless. `zephyr` would +also work, but would additionally trip the UNRELATED tan-cli#309 Zephyr- +boilerplate guard for a probe command that (deliberately, for this file's own +purpose) never produces real Zephyr CMake evidence; `test_execute_zephyr_ +guard.py` owns that guard's own coverage.""" +import json +import os +import shutil +import sys +from pathlib import Path + +from tan.core.build_plan import parse_build_plan +from tan.commands.build.execute import execute_slices + +PYTHON = json.dumps(sys.executable) +SEP = os.pathsep + + +def _plan(command: str, env: str = "{}", env_append_path: str = "{}") -> str: + return f"""{{ + "schemaVersion": 1, "generatedBy": "g", "boardYaml": "/w/board.yaml", "sku": "S", + "buildRoot": "build", "sharedArtefacts": [], "warnings": [], + "executionPolicy": {{"missingTool": "skip", "nullCommand": "skip", "unknownBackend": "fail"}}, + "slices": [{{ + "coreId": "c1", "backend": "baremetal", "buildDir": "build/c1", "appDir": "app", + "configArtefacts": [], "toolchain": null, "artifacts": [], "debug": {{}}, + "command": {command}, "env": {env}, "envAppendPath": {env_append_path} + }}] + }}""" + + +def _make_workspace(tmp_path: Path) -> tuple[Path, Path, Path]: + """A manifest-verified west workspace (mirrors `test_execute.py`'s + tan-cli#307 `real_ws` fixture): `real_ws/.west/config` names + `real_ws/alp-sdk` as its manifest, and `real_ws/zephyr` stands in for the + Zephyr checkout `resolve_zephyr_base` looks for. Returns + `(real_ws, sdk_root, build_root)`.""" + real_ws = tmp_path / "real-ws" + sdk_root = real_ws / "alp-sdk" + sdk_root.mkdir(parents=True) + (real_ws / ".west").mkdir() + (real_ws / ".west" / "config").write_text("[manifest]\npath = alp-sdk\n", encoding="utf-8") + (real_ws / "zephyr").mkdir() + build_root = real_ws / "work" / "proj" + build_root.mkdir(parents=True) + return real_ws, sdk_root, build_root + + +def _probe_cmd(out_file: Path) -> str: + script = ( + "import json, os\n" + f"open({json.dumps(str(out_file))}, 'w').write(json.dumps(dict(os.environ)))\n" + ) + return json.dumps({"tool": sys.executable, "args": ["-c", script], "cwd": None}) + + +def test_fills_zephyr_base_and_extra_zephyr_modules_when_the_plan_carries_neither( + tmp_path, monkeypatch +): + """The behaviour tan-cli#308 reports missing: a plan slice with no + `ZEPHYR_BASE`/`EXTRA_ZEPHYR_MODULES` pin gets both filled from the + resolved workspace and `sdk_root`, not left to whatever the ambient + process env happens to hold. Fails before the fix (both keys silently + inherit whatever `dict(os.environ)` had -- `None`/unset in a scrubbed + test env) and passes after.""" + monkeypatch.delenv("ZEPHYR_BASE", raising=False) + monkeypatch.delenv("EXTRA_ZEPHYR_MODULES", raising=False) + real_ws, sdk_root, build_root = _make_workspace(tmp_path) + out_file = tmp_path / "env.json" + + out = execute_slices( + parse_build_plan(_plan(_probe_cmd(out_file))), + build_root=build_root, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + sdk_root=str(sdk_root), + ) + + assert out[0].status == "succeeded", out[0].message + seen = json.loads(out_file.read_text(encoding="utf-8")) + assert Path(seen["ZEPHYR_BASE"]).samefile(real_ws / "zephyr") + assert seen["EXTRA_ZEPHYR_MODULES"] == str(sdk_root) + + +def test_a_stale_ambient_zephyr_base_does_not_win_over_the_resolved_workspace( + tmp_path, monkeypatch +): + """tan-cli#308's actual reported defect: an exported `$ZEPHYR_BASE` left + over from an unrelated tree (a `source zephyr-env.sh`, or an older `tan + bootstrap` next-steps block) must not survive into the spawned child once + `tan` has resolved a real workspace of its own. `execute_slices` seeds + the child from `dict(os.environ)` first (line ~594) -- the ambient value + -- so this genuinely exercises the override, not just the gap-fill.""" + real_ws, sdk_root, build_root = _make_workspace(tmp_path) + stale = tmp_path / "stale-unrelated-zephyr" + stale.mkdir() + monkeypatch.setenv("ZEPHYR_BASE", str(stale)) + out_file = tmp_path / "env.json" + + out = execute_slices( + parse_build_plan(_plan(_probe_cmd(out_file))), + build_root=build_root, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + sdk_root=str(sdk_root), + ) + + assert out[0].status == "succeeded", out[0].message + seen = json.loads(out_file.read_text(encoding="utf-8")) + assert Path(seen["ZEPHYR_BASE"]).samefile(real_ws / "zephyr") + assert not Path(seen["ZEPHYR_BASE"]).samefile(stale) + + +def test_a_plan_pinned_extra_zephyr_modules_env_append_path_is_not_clobbered( + tmp_path, monkeypatch +): + """Plan wins: an SDK-emitted plan's own `envAppendPath.EXTRA_ZEPHYR_MODULES` + (the common case tan-cli#308's own severity note names) must survive + untouched -- not get overwritten with just the hand-derived `sdk_root`, + which would silently drop any OTHER module path the plan appended.""" + monkeypatch.delenv("EXTRA_ZEPHYR_MODULES", raising=False) + real_ws, sdk_root, build_root = _make_workspace(tmp_path) + out_file = tmp_path / "env.json" + + out = execute_slices( + parse_build_plan( + _plan( + _probe_cmd(out_file), + env_append_path='{"EXTRA_ZEPHYR_MODULES": ["/plan/other-module"]}', + ) + ), + build_root=build_root, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + sdk_root=str(sdk_root), + ) + + assert out[0].status == "succeeded", out[0].message + seen = json.loads(out_file.read_text(encoding="utf-8")) + assert seen["EXTRA_ZEPHYR_MODULES"] == "/plan/other-module" + # ZEPHYR_BASE is independent of this key -- still filled. + assert Path(seen["ZEPHYR_BASE"]).samefile(real_ws / "zephyr") + + +def test_an_inherited_pythonpath_is_still_extended_not_replaced(tmp_path, monkeypatch): + """Confirms the pre-existing "plan wins / CLI fills gaps" seeding + (`assemble_slice_env`, tan.core.plan_exec) still holds through + `execute_slices` after wiring the new zephyr gap-fillers alongside it -- + the new per-slice `slice_gap_fillers` list must not disturb the + envAppendPath-seeding path an unrelated var like `PYTHONPATH` takes.""" + real_ws, sdk_root, build_root = _make_workspace(tmp_path) + out_file = tmp_path / "env.json" + + out = execute_slices( + parse_build_plan( + _plan( + _probe_cmd(out_file), + env_append_path='{"PYTHONPATH": ["/plan/scripts"]}', + ) + ), + build_root=build_root, + env_lookup=lambda k: "/inherited/scripts" if k == "PYTHONPATH" else None, + gap_fillers=[], + on_output=lambda s: None, + sdk_root=str(sdk_root), + ) + + assert out[0].status == "succeeded", out[0].message + seen = json.loads(out_file.read_text(encoding="utf-8")) + assert seen["PYTHONPATH"] == f"/inherited/scripts{SEP}/plan/scripts" + + +# -------------------------------------------------------------------------- +# tan-cli#308 x tan-cli#336: the two fixes meet on the SAME `env` dict, and +# the naive composition silently cancels one of them. +# +# Every test above drives a `backend: baremetal` slice whose `tool` is the +# interpreter itself, so `is_west` is False and #336's `env.pop` never runs -- +# which is exactly why the broken composition passed the whole suite. These +# two use `tool: "west"` (the only shape that reaches the pop) and assert the +# composed outcome, not either fix in isolation. +# -------------------------------------------------------------------------- + + +def _west_plan(out_file: Path, env: str = "{}") -> str: + """A `tool: "west"` slice -- the ONLY shape `is_west` is true for, and so + the only one the tan-cli#336 `ZEPHYR_BASE` pop is reachable through. Kept + `backend: baremetal` for the same reason the rest of this file is (the + unrelated tan-cli#309 Zephyr guard owns its own suite), and `args[0]` is + deliberately NOT `"build"` so tan-cli#307's `_pin_west_workspace` leaves + cwd and args verbatim and the probe can just dump its env.""" + script = ( + "import json, os, sys\n" + f"open({json.dumps(str(out_file))}, 'w').write(json.dumps(dict(os.environ)))\n" + ) + return _plan(json.dumps({"tool": "west", "args": ["-c", script], "cwd": None}), env=env) + + +def _plant_west(build_root: Path) -> None: + """`execute_slices` rewrites `tool == "west"` to the workspace venv's own + `west`; plant a spawnable one there (a renamed copy of this interpreter, + the same recipe `test_execute.py::_plant_spawnable_west` uses) so the + slice actually dispatches instead of skipping on `missingTool`.""" + from tan.core.venv import venv_layout + + layout = venv_layout(os.name == "nt") + west_path = build_root / ".venv" / layout.bin_dir / layout.west + west_path.parent.mkdir(parents=True, exist_ok=True) + if os.name == "nt": + for dll in Path(sys.executable).parent.glob("*.dll"): + shutil.copy(dll, west_path.parent / dll.name) + shutil.copy(sys.executable, west_path) + else: + west_path.write_text( + f'#!/bin/sh\nexec {json.dumps(sys.executable)} "$@"\n', encoding="utf-8" + ) + os.chmod(west_path, 0o755) + + +def test_the_336_pop_does_not_strip_the_308_gap_filled_zephyr_base(tmp_path, monkeypatch): + """The composition regression. tan-cli#336 pops an inherited + `ZEPHYR_BASE` off a west slice's env; tan-cli#308 FILLS that same key + from the resolved workspace. #308's fill lands via `assemble_slice_env`, + so a pop keyed on the plan's `sl.env` alone cannot see it and strips it + right back out -- on precisely the slices #308 exists to serve (the ones + that do NOT pin the key themselves). + + Fails on the naive merge with `ZEPHYR_BASE` absent from the child's env + entirely; passes once the pop is keyed on the assembled `slice_env`.""" + monkeypatch.setenv("ZEPHYR_BASE", str(tmp_path / "stale-ambient")) + real_ws, sdk_root, build_root = _make_workspace(tmp_path) + _plant_west(build_root) + out_file = tmp_path / "env.json" + + out = execute_slices( + parse_build_plan(_west_plan(out_file)), + build_root=build_root, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + sdk_root=str(sdk_root), + ) + + assert out[0].status == "succeeded", out[0].message + seen = json.loads(out_file.read_text(encoding="utf-8")) + assert "ZEPHYR_BASE" in seen, "#336's pop stripped the value #308 had just filled" + assert Path(seen["ZEPHYR_BASE"]).samefile(real_ws / "zephyr") + + +def test_a_stale_ambient_zephyr_base_is_still_dropped_when_308_cannot_fill( + tmp_path, monkeypatch +): + """The other half: #336 must still fire where #308 has nothing to give. + A workspace that resolved but was never `west update`d has no `zephyr/`, + so `zephyr_env_overrides` yields no `ZEPHYR_BASE` -- and without the pop + the child inherits the stale ambient one and west trusts it unchecked + (`west/app/main.py::set_zephyr_base` has no existence check).""" + stale = tmp_path / "stale-ambient" + stale.mkdir() + monkeypatch.setenv("ZEPHYR_BASE", str(stale)) + real_ws, sdk_root, build_root = _make_workspace(tmp_path) + (real_ws / "zephyr").rmdir() # resolved workspace, never `west update`d + _plant_west(build_root) + out_file = tmp_path / "env.json" + + out = execute_slices( + parse_build_plan(_west_plan(out_file)), + build_root=build_root, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + sdk_root=str(sdk_root), + ) + + assert out[0].status == "succeeded", out[0].message + seen = json.loads(out_file.read_text(encoding="utf-8")) + assert "ZEPHYR_BASE" not in seen, f"stale ambient value survived: {seen.get('ZEPHYR_BASE')}" + + +def test_a_plan_pinned_zephyr_base_survives_both_the_fill_and_the_pop(tmp_path, monkeypatch): + """"Plan wins" is the invariant BOTH fixes claim to respect, and it is + the one a wrong pop condition breaks most visibly. A slice pinning + `ZEPHYR_BASE` in its own `env` must reach the child with that exact + value -- neither overwritten by #308's gap filler nor popped by #336.""" + monkeypatch.setenv("ZEPHYR_BASE", str(tmp_path / "stale-ambient")) + real_ws, sdk_root, build_root = _make_workspace(tmp_path) + _plant_west(build_root) + out_file = tmp_path / "env.json" + pinned = str(tmp_path / "plan-pinned-zephyr") + + out = execute_slices( + parse_build_plan(_west_plan(out_file, env=json.dumps({"ZEPHYR_BASE": pinned}))), + build_root=build_root, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + sdk_root=str(sdk_root), + ) + + assert out[0].status == "succeeded", out[0].message + seen = json.loads(out_file.read_text(encoding="utf-8")) + assert seen["ZEPHYR_BASE"] == pinned diff --git a/python/tests/commands/test_execute_zephyr_guard.py b/python/tests/commands/test_execute_zephyr_guard.py new file mode 100644 index 00000000..b869b497 --- /dev/null +++ b/python/tests/commands/test_execute_zephyr_guard.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#309 end to end (upstream tan-cli #97): `execute_slices` must +refuse an `os: zephyr` slice whose CMake configure never loaded Zephyr's +boilerplate, whatever the tool's own exit code says -- reproduces the +reported defect (`tan init --template minimal-app` -> `tan build` reporting +`[+] ok` for a plain host binary) at the `execute_slices` layer. The guard +stays regardless of whether any one template's own CMake is correct today -- +it is the safety net for every `os: zephyr` slice, not a substitute for a +correct scaffold. `minimal-app`'s own CMake shape (root `find_package(Zephyr +...)` + `project()`, `src/CMakeLists.txt` contributing via `target_sources(app +...)`, and `board.yaml`'s `app:` pointed at the directory that actually holds +`find_package`) is fixed at the source in `tan/core/scaffold.py`; see +`python/tan/templates/vendored/MANIFEST.md`'s "`minimal-app`" section for the +two-part defect (CMake shape, then `app:` target) and why this guard is still +worth keeping regardless.""" +import json +import sys + +from tan.core.build_plan import parse_build_plan +from tan.commands.build.execute import execute_slices + +PYTHON = json.dumps(sys.executable) +_TRUE_CMD = f'{{"tool": {PYTHON}, "args": ["-c", "pass"], "cwd": "build/c1"}}' + + +def _plan(command: str, backend: str = "zephyr", args_extra: str = "") -> str: + return f"""{{ + "schemaVersion": 1, "generatedBy": "g", "boardYaml": "/w/board.yaml", "sku": "S", + "buildRoot": "build", "sharedArtefacts": [], "warnings": [], + "executionPolicy": {{"missingTool": "skip", "nullCommand": "skip", "unknownBackend": "fail"}}, + "slices": [{{ + "coreId": "c1", "backend": "{backend}", "buildDir": "build/c1", "appDir": "app", + "configArtefacts": [], "toolchain": null, "artifacts": [], "debug": {{}}, + "command": {command}, "env": {{}}, "envAppendPath": {{}} + }}] + }}""" + + +def test_a_zephyr_slice_that_exits_0_with_no_zephyr_evidence_is_reported_failed(tmp_path): + """The tan-cli#309 defect itself: exit code 0 alone must not be reported + `succeeded` for a declared `os: zephyr` core whose build dir shows no + sign Zephyr's CMake boilerplate ever ran.""" + out = execute_slices( + parse_build_plan(_plan(_TRUE_CMD)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "failed" + # The tool really did exit 0 -- the guard refuses the RESULT, not the + # exit, so `exit_code` must stay the real, honest value. + assert out[0].exit_code == 0 + assert out[0].output_artefact is None + assert out[0].build_dir is None + + +def test_the_refusal_message_matches_the_oracle_verbatim(tmp_path): + """Confirmed against the compiled oracle's own runtime string (`cargo + test -p alp-tan-cli --bin tan + native_execute_refuses_a_zephyr_slice_whose_configure_never_loaded_zephyr + -- --nocapture`), not transcribed from source alone.""" + out = execute_slices( + parse_build_plan(_plan(_TRUE_CMD)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].message == ( + "core `c1` is declared `os: zephyr`, but the build in `build/c1` never loaded " + "Zephyr (no ZEPHYR_BASE in its CMakeCache.txt and no zephyr/ output) — its " + "CMakeLists.txt must call `find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})` " + "before `project()`; without it CMake builds a plain host binary, not firmware. " + "Scaffold a working app with `tan init --template zephyr-app`, or point the " + "core's `app:` at one that does." + ) + + +def test_a_non_zephyr_backend_is_never_subject_to_the_guard(tmp_path): + out = execute_slices( + parse_build_plan(_plan(_TRUE_CMD, backend="baremetal")), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "succeeded" + + +def test_a_genuine_build_failure_is_reported_on_its_own_terms_not_the_guard(tmp_path): + """A real nonzero exit must not be swallowed into the guard's own + message -- the guard only ever fires on an otherwise-`succeeded` slice.""" + cmd = f'{{"tool": {PYTHON}, "args": ["-c", "raise SystemExit(3)"], "cwd": "build/c1"}}' + out = execute_slices( + parse_build_plan(_plan(cmd)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "failed" + assert out[0].exit_code == 3 + assert "os: zephyr" not in (out[0].message or "") + + +def test_a_real_zephyr_build_dir_is_accepted(tmp_path): + """The other half of the guard: it must not fail a REAL Zephyr build. + `ZEPHYR_BASE:` in the build dir's own `CMakeCache.txt` is the primary + signal, pinned here WITHOUT the directory fallback -- a verified real + Zephyr slice carries `ZEPHYR_BASE:PATH=...` and has no `zephyr/` dir.""" + nested = tmp_path / "build" / "c1" / "build" + nested.mkdir(parents=True) + (nested / "CMakeCache.txt").write_text( + "CMAKE_PROJECT_NAME:STATIC=zephyr\nZEPHYR_BASE:PATH=/work/zephyr\n", encoding="utf-8" + ) + out = execute_slices( + parse_build_plan(_plan(_TRUE_CMD)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "succeeded", out[0].message + + +def test_a_sysbuild_slice_evidenced_one_level_down_is_accepted(tmp_path): + """`--sysbuild` nests the real per-image Zephyr build one directory + deeper than its own superbuild top level -- without the one-level-down + look this would fail a correct V2N sysbuild build.""" + nested = tmp_path / "build" / "c1" / "build" / "alp_app" + (nested / "zephyr").mkdir(parents=True) + out = execute_slices( + parse_build_plan(_plan(_TRUE_CMD)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "succeeded", out[0].message + + +def test_the_guard_stands_down_when_the_build_dir_is_overridden(tmp_path): + """Same refusal `resolve_zephyr_artefact` and the sdk-switch wipe already + make: with `-d`/`--build-dir` west wrote somewhere this cannot see, so + there is no evidence to judge -- the guard must not fail a build it + cannot inspect.""" + cmd = json.dumps( + {"tool": sys.executable, "args": ["-c", "pass", "-d", "../elsewhere"], "cwd": "build/c1"} + ) + out = execute_slices( + parse_build_plan(_plan(cmd)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "succeeded", out[0].message diff --git a/python/tests/commands/test_faultdecode_command.py b/python/tests/commands/test_faultdecode_command.py index 62ec8fb1..433344d2 100644 --- a/python/tests/commands/test_faultdecode_command.py +++ b/python/tests/commands/test_faultdecode_command.py @@ -21,7 +21,6 @@ import importlib.util import json -import os import sys import tempfile from pathlib import Path @@ -31,6 +30,7 @@ from typer.testing import CliRunner from tan.commands.faultdecode_cmd import faultdecode +from tests.conftest import REAL_ENVIRON app = typer.Typer() app.command("faultdecode")(faultdecode) @@ -42,8 +42,16 @@ def _resolve_oracle_path() -> Path | None: `tests/core/test_faultdecode.py::_resolve_oracle_path`: `ALP_SDK_ROOT` if set (a set-but-missing value RAISES rather than skipping), else an `alp-sdk` checkout sitting next to this repo at any ancestor level. - Returns `None` only when neither is present.""" - override = os.environ.get("ALP_SDK_ROOT") + Returns `None` only when neither is present. + + Reads `REAL_ENVIRON` (captured at collection time in `tests/conftest.py`), + NOT `os.environ` -- this function runs from inside test bodies (via + `_load_oracle_command`), by which point the autouse + `_scrub_sdk_discovery_env` fixture has already deleted `ALP_SDK_ROOT` + from the live process environment, so an `os.environ` read here always + saw it gone and every oracle-parity test below skipped unconditionally + (tan-cli#254/#256 fix).""" + override = REAL_ENVIRON.get("ALP_SDK_ROOT") if override: candidate = Path(override) / "scripts" / "alp_cli" / "faultdecode.py" if not candidate.is_file(): @@ -207,6 +215,33 @@ def test_project_and_sdk_root_are_accepted_but_unused(): assert result.exit_code == 0 +def test_full_global_flag_set_is_accepted_even_when_meaningless(): + """The oracle's clap `GlobalArgs` are `global = true`, so `faultdecode` + accepts `--board-yaml`/`--target`/`--all`/`--verbose`/`--quiet`/ + `--non-interactive`/`--ci` even though it never reads any of them -- + confirmed live: `tan.exe faultdecode --sdk-root --board-yaml x + --target t --all --verbose --quiet --non-interactive --ci --cfsr + 0x8200` is a forwarder-shaped SDK-root-unresolved refusal, not a parse + error, on the oracle; this port's native `faultdecode` needs no SDK root + at all (see the module docstring) so the SAME argv succeeds outright. + Regression for the Click "No such option" usage error (exit 2) this + port used to raise for each of these instead (tan-cli#256).""" + result = runner.invoke( + app, + [ + "--board-yaml", "x.yaml", + "--target", "zephyr-conf", + "--all", + "--verbose", + "--quiet", + "--non-interactive", + "--ci", + "--cfsr", "0x8200", + ], + ) + assert result.exit_code == 0, result.output + + def test_format_json_after_subcommand_is_equivalent_to_json_flag(): """`--format json`, declared after the subcommand name (Typer's own option), must behave exactly like `--json`: the oracle maps the global diff --git a/python/tests/commands/test_flash_command.py b/python/tests/commands/test_flash_command.py index 2d419f6c..bf863f5c 100644 --- a/python/tests/commands/test_flash_command.py +++ b/python/tests/commands/test_flash_command.py @@ -657,6 +657,39 @@ class _Ctx: assert payload["issues"], "a failure must always carry an issue" +# ── swd_probe Commander script quoting (tan-cli#369, test gap per #373) ──── + + +def test_jlink_commander_script_quotes_a_spaced_loadbin_path(): + """tan-cli#369 fixed `commander_path` (unquoted whitespace silently + truncates SEGGER's line, e.g. `C:\\Program Files\\...` -> `C:\\Program`) + but shipped with no test exercising `jlink_commander_script` -- the + `swd_probe` generator that is the only real caller -- against a spaced + path at all (tan-cli#373). A raw `.bin` takes the `loadbin` line.""" + script = flash_plan.jlink_commander_script( + "C:\\Program Files\\alp\\build\\zephyr.bin", "0x08000000", True + ) + assert 'loadbin "C:\\Program Files\\alp\\build\\zephyr.bin", 0x08000000' in script + + +def test_jlink_commander_script_quotes_a_spaced_loadfile_path(): + """The non-`.bin` (`loadfile`) branch must quote a spaced path too -- the + same SEGGER whitespace-split hazard applies to it, and `commander_path` + is shared by both `jlink_commander_script` branches.""" + script = flash_plan.jlink_commander_script( + "C:\\Program Files\\alp\\build\\zephyr.elf", "0x08000000", False + ) + assert 'loadfile "C:\\Program Files\\alp\\build\\zephyr.elf"' in script + + +def test_jlink_commander_script_leaves_an_unspaced_path_unquoted(): + """The common case -- every already-measured oracle/bench script path -- + must render byte-identical to before tan-cli#369's quoting fix.""" + script = flash_plan.jlink_commander_script("/build/zephyr.bin", "0x08000000", True) + assert "loadbin /build/zephyr.bin, 0x08000000" in script + assert '"' not in script + + # ── Flow D: no oracle counterpart, so it is pinned entirely here ──────────── FLOW_D_ARGS = { @@ -797,6 +830,28 @@ def test_flow_d_script_writes_both_blobs_verifies_and_pin_resets(): ) +def test_flow_d_script_quotes_a_path_containing_a_space(tmp_path): + """tan-cli#369: SEGGER's J-Link Commander splits an unquoted script line + on whitespace, so an unquoted `loadbin C:\\Program Files\\...` truncates + to `C:\\Program` -- a normal Windows SETOOLS install path. `atoc` under a + directory with a space must be quoted; the no-space `artefact` line + (proven byte-identical above) must NOT be, so this only widens the + render, never narrows it.""" + spaced_dir = tmp_path / "Program Files" / "alif" / "setools" / "build" + spaced_dir.mkdir(parents=True) + atoc = spaced_dir / "AppTocPackage.bin" + atoc.write_bytes(b"\x00" * 8) + + plan = plan_alif_mram_jlink( + flow_d_inputs(atoc=str(atoc), confirm=True), lambda t: t == "JLinkExe" + ) + script = plan.jlink_script or "" + assert f'loadbin "{atoc}" 0x8057F5B0' in script, script + assert f'verifybin "{atoc}" 0x8057F5B0' in script, script + # The unquoted no-space artefact line is untouched. + assert "loadbin /build/zephyr/zephyr.bin 0x80010000" in script, script + + def test_flow_d_is_confirm_gated_like_every_other_persistent_write(): unconfirmed = plan_alif_mram_jlink(flow_d_inputs(), lambda t: True) assert unconfirmed.planning_only is True @@ -887,6 +942,27 @@ def test_flow_d_present_but_null_or_empty_slot0_load_address_refuses(bad_value): assert "slot0_load_address" in str(raised.value) +def test_flow_d_mramxip_shape_refuses_a_non_raw_bin_artefact(): + """A slot0-linked artefact that is not a raw `.bin` (e.g. `zephyr.elf`) must + never reach `loadbin ... slot0_load_address` -- that writes the artefact's + own headers into MRAM at the load address instead of the app image + (tan-cli#311). Unlike `plan_swd_probe`'s ELF/HEX fallback to `loadfile`, + there is no fallback here: `loadfile` would silently ignore + `slot0_load_address`, which is a worse failure than a refusal.""" + args = {**FLOW_D_ARGS, "confirm": True} + with pytest.raises(FlashPlanError) as raised: + plan_alif_mram_jlink( + FlashInputs( + artefact="/build/zephyr/zephyr.elf", flash_args=args, core_id="m", sku="S" + ), + lambda t: True, + ) + message = str(raised.value) + assert "zephyr.elf" in message + assert "zephyr.bin" in message + assert "slot0_load_address" in message + + def test_flow_d_holds_no_part_number_of_its_own(): """The whole point of resolving the profile from metadata. `alif`, `AE822...` and the MRAM addresses must appear NOWHERE in the module -- not as @@ -1003,6 +1079,149 @@ def test_flow_d_preflight_present_but_null_or_empty_jlink_device_refuses(bad_val assert "jlink_device" in str(raised.value) +def _flow_d_preflight_inputs(): + args = {**FLOW_D_ARGS, "expect_dpidr": "0x4C013477", "jlink_device": "Generic-Attach"} + return FlashInputs(artefact="/b/z.bin", flash_args=args, core_id="m", sku="S") + + +def _stub_flow_d_probe(monkeypatch, stdout: str, stderr: str = "", success: bool = True): + """Make `_flow_d_preflight` reach a fake connect banner without a real + J-Link on PATH or an actual spawn -- `_tool_available`/ + `_programs_resolved_in_venv` are the tool-gate and venv-resolution steps + ahead of the spawn, neither of which this test cares about.""" + monkeypatch.setattr(flash_cmd, "_tool_available", lambda *_a, **_k: True) + monkeypatch.setattr(flash_cmd, "_programs_resolved_in_venv", lambda argv, _venv_bin: argv) + monkeypatch.setattr( + flash_cmd, + "_spawn_jlink", + lambda *_a, **_k: flash_cmd._Outcome(success=success, stdout=stdout, stderr=stderr), + ) + + +def test_flow_d_preflight_a_different_reported_dp_id_keeps_the_wiring_message(monkeypatch): + """tan-cli#312, case (a): the probe DID connect and reported a real, just + different, SW-DP ID -- a genuine wrong-board / wiring / probe-selection + problem, so the original remediation stands unchanged.""" + _stub_flow_d_probe( + monkeypatch, + stdout="Connecting to target via SWD\nFound SW-DP with ID 0x2BA01477\n", + ) + message = flash_cmd._flow_d_preflight(_flow_d_preflight_inputs()) + assert message is not None + assert "Check the wiring and which board is physically attached" in message + assert "re-enumerat" not in message + + +def test_flow_d_preflight_wrong_dp_id_names_the_sw_dp_id_not_jlink_serial(monkeypatch): + """tan-cli#369: the wrong-DP-ID remediation used to read as "pin + jlink_serial to fix this" -- wrong on the bench this preflight actually + caught a mismatch on, where a cloned/shared USB serial made jlink_serial + ambiguous between two physical probes. The remediation must name the + SW-DP ID as the real discriminator and say plainly that a shared/cloned + serial cannot be disambiguated by jlink_serial alone.""" + _stub_flow_d_probe( + monkeypatch, + stdout="Connecting to target via SWD\nFound SW-DP with ID 0x2BA01477\n", + ) + message = flash_cmd._flow_d_preflight(_flow_d_preflight_inputs()) + assert message is not None + assert "SW-DP ID is the real" in message + assert "cannot disambiguate" in message + assert "CLONED serial" in message + assert "jlink_serial" in message + + +def test_flow_d_preflight_no_dp_id_at_all_gets_the_re_enumeration_message(monkeypatch): + """tan-cli#312, case (b): measured verbatim on the rc3 bench run -- the + probe refused the connect outright, mid re-enumeration after a prior + `JLinkExe` close, and reported no SW-DP ID whatsoever. This must NOT get + the wiring/jlink_serial sentence: nothing was wrong with either.""" + _stub_flow_d_probe( + monkeypatch, + stdout="Connecting to J-Link ...FAILED: Cannot connect to the probe/programmer.\n", + stderr="J-Link uptime (since boot): 0d 00h 00m 01s\n", + success=False, + ) + message = flash_cmd._flow_d_preflight(_flow_d_preflight_inputs()) + assert message is not None + assert "re-enumerat" in message + assert "Check the probe selection" not in message + assert "0x4C013477" in message + + +def test_flow_d_preflight_an_unrecognised_banner_falls_back_to_the_wiring_message(monkeypatch): + """Conservative by design (tan-cli#312): a banner with neither a + recognisable DP-ID token NOR SEGGER's own connect-refused wording is not + confidently "just re-enumerating" -- the detector must not guess the + wiring is fine, so this keeps the original sentence. + + **tan-cli#373**: no DP ID was reported here, so this must get the + ORIGINAL probe-selection/`jlink_serial` sentence -- not #369's + cloned-serial text, which only applies when a board DID answer with a + different ID (the wrong-DP-ID test below covers that one).""" + _stub_flow_d_probe(monkeypatch, stdout="some unrecognised probe banner\n") + message = flash_cmd._flow_d_preflight(_flow_d_preflight_inputs()) + assert message is not None + assert "Check the probe selection (flash_args.jlink_serial) and the wiring" in message + assert "re-enumerat" not in message + assert "CLONED serial" not in message + + +def test_flow_d_preflight_a_target_level_cannot_connect_keeps_the_wiring_message(monkeypatch): + """tan-cli#312 review finding: an unplugged SWD ribbon / no board present + produces "Cannot connect to target." -- a genuine wiring problem, not a + re-enumerating probe. This must NOT get the "not a wiring... problem" + re-enumeration message: on a bench that would turn a real unplugged cable + into an infinite wait-and-retry loop instead of the correct remediation. + + **tan-cli#373**: no DP ID was reported here either, so the ORIGINAL + probe-selection sentence is correct, not the cloned-serial text -- #369's + rewrite gave this banner the cloned-serial message too, which never + applies (no ID was even read to compare).""" + _stub_flow_d_probe( + monkeypatch, + stdout=( + "Connecting to target via SWD\n" + "InitTarget() start\n" + "InitTarget() end\n" + "Cannot connect to target.\n" + ), + success=False, + ) + message = flash_cmd._flow_d_preflight(_flow_d_preflight_inputs()) + assert message is not None + assert "Check the probe selection (flash_args.jlink_serial) and the wiring" in message + assert "re-enumerat" not in message + assert "CLONED serial" not in message + + +def test_flow_d_preflight_a_wrong_jlink_serial_keeps_the_wiring_message(monkeypatch): + """tan-cli#312 review finding: a probe that IS reachable via USB but + refuses the requested `flash_args.jlink_serial` prints "Cannot connect to + J-Link." -- a real probe-selection problem, so this keeps the original + wiring/`jlink_serial` remediation rather than the re-enumeration message. + + **tan-cli#373**: this is the regression #367's own review pattern warned + about -- this test's docstring already promised the ORIGINAL wiring/ + `jlink_serial` sentence, but its body asserted a phrase that actually + belongs to #369's cloned-serial rewrite (both messages happen to share + the words "Check the wiring...physically attached"). No DP ID was + reported here, so the ORIGINAL sentence -- naming `jlink_serial` + explicitly as the fix on a multi-probe host -- is correct; the + cloned-serial text does not apply.""" + _stub_flow_d_probe( + monkeypatch, + stdout="Connecting to J-Link via USB...FAILED: Cannot connect to J-Link.\n", + success=False, + ) + message = flash_cmd._flow_d_preflight(_flow_d_preflight_inputs()) + assert message is not None + assert "Check the probe selection (flash_args.jlink_serial) and the wiring" in message + assert "tan-cli#353" in message + assert "re-enumerat" not in message + assert "CLONED serial" not in message + + def test_flow_d_needs_jlink_on_path_for_a_real_run(): with pytest.raises(FlashPlanError) as raised: plan_alif_mram_jlink(flow_d_inputs(confirm=True), lambda t: False) @@ -1246,6 +1465,343 @@ def test_flow_d_end_to_end_fails_when_the_map_file_never_materialised(tmp_path): assert "flash_args.atoc_address" in entry["message"] +# ── tan-cli#353's remaining half: SETOOLS integration for the AEN801 slot0 +# flash. The alp-sdk manifest measured on real silicon (e1m-aen-evk-01, E8 +# AE822) emits ONLY `flash_args.jlink_flash_device` -- no `atoc`/`atoc_map`/ +# `atoc_address` at all -- so a customer used to hit `plan_alif_mram_jlink`'s +# bare "both required" refusal with no path from there to a working flash. +# These three prove the maintainer's minimum bar: (a) a resolved SETOOLS path +# signs for real and the derived `atoc_address` reaches the actual +# `loadbin`/`verifybin` pair; (b) an unresolved one refuses with the SETOOLS +# guidance, not the bare field error; (c) `--dry-run` signs nothing. + + +def _setools_script_name() -> str: + """`.bat` on Windows -- a batch-content file needs the extension to be + directly spawnable via `subprocess.run(..., shell=False)` (measured: + an extension-less same-content file fails with WinError 193) -- the real + bare `app-gen-toc` name (`tan.core.setools.APP_GEN_TOC`) everywhere else, + where a POSIX shebang script IS spawnable extension-less.""" + return "app-gen-toc.bat" if os.name == "nt" else "app-gen-toc" + + +def _write_working_app_gen_toc(dest: Path, address: str = "0x8057ea50") -> str: + """A fake `app-gen-toc` that writes a real `build/app-package-map.txt` + + `build/AppTocPackage.bin` under its OWN cwd and exits 0 -- proves the + WIRING (`tan.core.setools.sign_slot0`'s own tests cover the failure + shapes), never a real SETOOLS (license-gated, not redistributed, and not + needed to prove this).""" + if os.name == "nt": + dest.write_text( + "@echo off\r\n" + "if not exist build mkdir build\r\n" + f">build\\app-package-map.txt echo APP Package Start Address: {address}\r\n" + "echo fake-atoc-bytes> build\\AppTocPackage.bin\r\n" + "exit /b 0\r\n", + encoding="utf-8", + ) + else: + dest.write_text( + "#!/bin/sh\n" + "mkdir -p build\n" + f'printf "APP Package Start Address: {address}\\n" > build/app-package-map.txt\n' + 'printf "fake-atoc-bytes\\n" > build/AppTocPackage.bin\n' + "exit 0\n", + encoding="utf-8", + ) + os.chmod(dest, 0o755) + return str(dest) + + +def test_flow_d_setools_signs_when_the_manifest_supplies_nothing_signing_related( + tmp_path, monkeypatch +): + """(a) A manifest carrying ONLY `jlink_flash_device` + `slot0_load_address` + -- alp-sdk's real current AEN801 emit plus the one key tan cannot derive, + measured -- gets a REAL SETOOLS sign when `flash_args.setools_dir` + resolves, and the DERIVED `atoc_address` reaches + `plan_alif_mram_jlink`'s actual `loadbin`/`verifybin` pair -- not just + `_resolve_flow_d_atoc_via_setools`'s own return value.""" + from tan.commands.flash_cmd import _Context, _is_file, _resolve_flow_d_atoc_via_setools + from tan.core import setools as setools_module + from tan.core.flash_plan import validate_flow_d_shape + + setools_dir = tmp_path / "setools" + setools_dir.mkdir() + name = _setools_script_name() + if name != setools_module.APP_GEN_TOC: + # `find_app_gen_toc`'s OWN lookup runs unmodified below -- only the + # name it looks for changes, to the one filename THIS host can + # actually spawn (see `_setools_script_name`'s own docstring). + monkeypatch.setattr(setools_module, "APP_GEN_TOC", name) + script = _write_working_app_gen_toc(setools_dir / name) + + build_root = tmp_path / "build" + build_root.mkdir() + artefact = build_root / "zephyr.bin" + artefact.write_bytes(b"\x50\x42\x00\x20" + b"\x00" * 64) + + flash_args = { + "jlink_flash_device": "PART_PROFILE", + "slot0_load_address": "0x80010000", + "setools_dir": str(setools_dir), + } + ctx = _Context( + sku="S", + build_root=str(build_root), + sdk_root=str(tmp_path), + dry_run=False, + skip_missing_tools=False, + force_confirm=False, + capture=True, + ) + shape = validate_flow_d_shape(flash_args, str(artefact), _is_file) + merged, note = _resolve_flow_d_atoc_via_setools(flash_args, shape, ctx, "m55_he") + + # tan-cli#373: a real (non-dry-run) sign now returns an informational + # NOTE (not `None`) naming which SETOOLS install actually signed -- + # `setools.source` used to reach a customer only via a FAILURE message. + # `flash_args.setools_dir` is what resolved it here, so its OWN value + # names the source, matching `resolve_setools_dir`'s own precedence text. + assert note is not None + assert str(setools_dir) in note + assert "flash_args.setools_dir" in note + assert merged["atoc_address"] == "0x8057ea50" + assert Path(merged["atoc"]).is_file() + assert Path(script).is_file() # the fake tool itself was never deleted/moved + + plan = plan_alif_mram_jlink( + FlashInputs(artefact=str(artefact), flash_args=merged, core_id="m55_he", sku="S"), + lambda _t: True, + ) + script_text = plan.jlink_script or "" + assert f"loadbin {merged['atoc']} 0x8057ea50" in script_text, script_text + assert f"verifybin {merged['atoc']} 0x8057ea50" in script_text, script_text + + +def test_flow_d_end_to_end_refuses_with_setools_guidance_when_unresolved(tmp_path): + """(b) The FIRST failure the ticket measures on real silicon: a fresh + AEN801 manifest carrying only `jlink_flash_device`, no `SETOOLS_DIR` and + no `flash_args.setools_dir` anywhere. Must surface the SETOOLS guidance + refusal -- naming that a signed ATOC is needed, that SETOOLS is + license-gated, and how to point tan at it -- not + `plan_alif_mram_jlink`'s bare 'flash_args.atoc ... required' field + message. `--dry-run`: the SAME reason every other CLI-level Flow D + refusal test above uses it -- it bypasses the JLinkExe PATH gate, which + is not what this test is about.""" + manifest = """schema_version: 1 +hw_info: {sku: S} +slices: +- {core_id: m55_he, os: zephyr, output_artefact: zephyr.bin, status: ok, + flash_method: zephyr_west_flash, + flash_args: {jlink_flash_device: AE822FA0E5597LS0_M55_HE}} +helper_mcus: [] +boot_order: [] +""" + exit_code, out, _ = run_flash( + tmp_path, "--format", "json", "--dry-run", manifest=manifest, + env={"SETOOLS_DIR": ""}, + ) + payload = envelope(out) + assert exit_code == 1 + entry = payload["data"]["entries"][0] + assert entry["status"] == "failed" + assert "SETOOLS" in entry["message"] + assert "license-gated" in entry["message"] + assert "--setools-dir" in entry["message"] + assert "SETOOLS_DIR=" in entry["message"] + assert "flash_args.setools_dir" in entry["message"] + # NOT the old bare field message a customer has never heard of app-gen-toc + # from. + assert "both required" not in entry["message"] + assert codes(payload) == ["flash.entry-failed"] + + +def test_flow_d_setools_dir_precedence_is_flag_then_env_then_manifest(tmp_path): + """tan-cli#368's acceptance criterion: with all three sources set to + DIFFERENT (all app-gen-toc-less) directories, the flag wins; with only + the environment and the manifest set, the environment wins. Proven via + `missing_tool_message`'s own source-naming (`setools.source`), not a real + sign -- none of the three directories holds a working `app-gen-toc`, so + `tan flash` always refuses, but WHICH directory (and which source phrase) + it names proves which one actually resolved.""" + manifest_dir = tmp_path / "from-manifest" + env_dir = tmp_path / "from-env" + flag_dir = tmp_path / "from-flag" + for d in (manifest_dir, env_dir, flag_dir): + d.mkdir() + + manifest = f"""schema_version: 1 +hw_info: {{sku: S}} +slices: +- {{core_id: m55_he, os: zephyr, output_artefact: zephyr.bin, status: ok, + flash_method: zephyr_west_flash, + flash_args: {{jlink_flash_device: PART_PROFILE, + setools_dir: "{manifest_dir.as_posix()}"}}}} +helper_mcus: [] +boot_order: [] +""" + # All three set -> the flag wins. + exit_code, out, _ = run_flash( + tmp_path, "--format", "json", "--dry-run", "--setools-dir", str(flag_dir), + manifest=manifest, env={"SETOOLS_DIR": str(env_dir)}, + ) + payload = envelope(out) + entry = payload["data"]["entries"][0] + assert exit_code == 1 + assert "the --setools-dir flag" in entry["message"] + assert str(flag_dir) in entry["message"] + assert str(env_dir) not in entry["message"] + assert str(manifest_dir) not in entry["message"] + + # No flag -> the environment beats the manifest. + exit_code, out, _ = run_flash( + tmp_path, "--format", "json", "--dry-run", + manifest=manifest, env={"SETOOLS_DIR": str(env_dir)}, + ) + payload = envelope(out) + entry = payload["data"]["entries"][0] + assert exit_code == 1 + assert "the SETOOLS_DIR environment variable" in entry["message"] + assert str(env_dir) in entry["message"] + assert str(manifest_dir) not in entry["message"] + + +def test_flow_d_dry_run_signs_nothing_via_setools(tmp_path): + """(c) `--dry-run` must NOT invoke `app-gen-toc`, even though SETOOLS + fully resolves here -- planning only. Proven two ways: the entry reports + a WOULD-sign preview (`status: ok`, not `planned`/`failed`), and nothing + a real sign would produce (`build/AppTocPackage.bin`, `build/config/`) + exists afterwards -- if `--dry-run` ever DID invoke the fake tool below, + it would either fail loudly (the file has no execute bit on POSIX) or, on + a host where it somehow ran, leave exactly the files these assertions + check for.""" + setools_dir = tmp_path / "setools" + setools_dir.mkdir() + # Present, but NEVER executed under --dry-run -- a real script would prove + # nothing extra here (see (a) above for that), so the placeholder is + # deliberately not spawnable at all (posix: no execute bit). + (setools_dir / "app-gen-toc").write_text("", encoding="utf-8") + + manifest = """schema_version: 1 +hw_info: {sku: S} +slices: +- {core_id: m55_he, os: zephyr, output_artefact: zephyr.bin, status: ok, + flash_method: zephyr_west_flash, + flash_args: {jlink_flash_device: PART_PROFILE, slot0_load_address: "0x80010000"}} +helper_mcus: [] +boot_order: [] +""" + (tmp_path / "build").mkdir(exist_ok=True) + (tmp_path / "build" / "zephyr.bin").write_bytes(b"\x50\x42\x00\x20" + b"\x00" * 64) + exit_code, out, _ = run_flash( + tmp_path, "--format", "json", "--dry-run", manifest=manifest, + env={"SETOOLS_DIR": str(setools_dir)}, + ) + payload = envelope(out) + assert exit_code == 0 + entry = payload["data"]["entries"][0] + assert entry["status"] == "ok" + assert "would sign" in entry["message"] + assert "app-gen-toc" in entry["message"] + assert not payload["issues"], payload["issues"] + # The real signing side effects a live run would produce -- absent. + assert not (setools_dir / "build" / "AppTocPackage.bin").exists() + assert not (setools_dir / "build" / "config").exists() + + +def test_flow_d_dry_run_with_setools_still_surfaces_a_half_armed_preflight(tmp_path): + """#366: the SETOOLS auto-sign preview used to return BEFORE `meta.build` + and BEFORE `validate_flow_d_preflight_args` ever ran, so a `--dry-run` + whose `SETOOLS_DIR` happens to resolve reported `ok:true` / exit 0 for a + manifest that would refuse a real (or SETOOLS-less) run outright -- a + disarmed SW-DP IDR guard passing a dry run. Same half-armed + `expect_dpidr`/`jlink_device` pair + `test_flow_d_dry_run_surfaces_a_half_armed_preflight_as_a_failure` proves + for the non-SETOOLS (`atoc` already supplied) path; this is the SETOOLS + path that bug actually lived in -- `SETOOLS_DIR` resolves, `app-gen-toc` + is found, and the manifest supplies neither `atoc` nor `atoc_map`.""" + setools_dir = tmp_path / "setools" + setools_dir.mkdir() + # Present, but must never be reached -- validation has to fail BEFORE any + # SETOOLS spawn is even considered. + (setools_dir / "app-gen-toc").write_text("", encoding="utf-8") + + manifest = """schema_version: 1 +hw_info: {sku: S} +slices: +- {core_id: m55_he, os: zephyr, output_artefact: zephyr.bin, status: ok, + flash_method: zephyr_west_flash, + flash_args: {jlink_flash_device: PART_PROFILE, slot0_load_address: "0x80010000", + expect_dpidr: "0x4C013477"}} +helper_mcus: [] +boot_order: [] +""" + (tmp_path / "build").mkdir(exist_ok=True) + (tmp_path / "build" / "zephyr.bin").write_bytes(b"\x50\x42\x00\x20" + b"\x00" * 64) + exit_code, out, _ = run_flash( + tmp_path, "--format", "json", "--dry-run", manifest=manifest, + env={"SETOOLS_DIR": str(setools_dir)}, + ) + payload = envelope(out) + assert exit_code == 1 + entry = payload["data"]["entries"][0] + assert entry["status"] == "failed" + assert "expect_dpidr" in entry["message"] + assert "jlink_device" in entry["message"] + # Never got far enough to preview a sign. + assert "would sign" not in entry["message"] + codes = {issue["code"] for issue in payload["issues"]} + assert "flash.entry-failed" in codes + + +def test_flow_d_dry_run_with_setools_still_surfaces_a_malformed_jlink_speed(tmp_path): + """tan-cli#373: #366 moved `jlink_flash_device`/`slot0_load_address` + validation ahead of the SETOOLS preview short-circuit, but not + `jlink_speed` -- that stayed checked only deep inside + `plan_alif_mram_jlink`, unreachable from the preview return exactly like + the `expect_dpidr`/`jlink_device` case above. Measured: `--dry-run` on + this manifest used to report `ok:true` / exit 0 despite `jlink_speed` + being a quoted string, and on a REAL run the SETOOLS auto-sign (writing + into the customer's install) would have happened before this refusal was + ever reached. `jlink_speed` is hoisted into `validate_flow_d_shape` + (shared by both the preview and `plan_alif_mram_jlink` itself), so it now + surfaces here too, before any SETOOLS spawn is even considered -- same + shape as the `expect_dpidr` fix above, different field.""" + setools_dir = tmp_path / "setools" + setools_dir.mkdir() + # Present, but must never be reached -- validation has to fail BEFORE any + # SETOOLS spawn is even considered. + (setools_dir / "app-gen-toc").write_text("", encoding="utf-8") + + manifest = """schema_version: 1 +hw_info: {sku: S} +slices: +- {core_id: m55_he, os: zephyr, output_artefact: zephyr.bin, status: ok, + flash_method: zephyr_west_flash, + flash_args: {jlink_flash_device: PART_PROFILE, slot0_load_address: "0x80010000", + jlink_speed: "fast"}} +helper_mcus: [] +boot_order: [] +""" + (tmp_path / "build").mkdir(exist_ok=True) + (tmp_path / "build" / "zephyr.bin").write_bytes(b"\x50\x42\x00\x20" + b"\x00" * 64) + exit_code, out, _ = run_flash( + tmp_path, "--format", "json", "--dry-run", manifest=manifest, + env={"SETOOLS_DIR": str(setools_dir)}, + ) + payload = envelope(out) + assert exit_code == 1 + entry = payload["data"]["entries"][0] + assert entry["status"] == "failed" + assert "flash_args.jlink_speed" in entry["message"] + assert "bare number" in entry["message"] + # Never got far enough to preview a sign. + assert "would sign" not in entry["message"] + codes = {issue["code"] for issue in payload["issues"]} + assert "flash.entry-failed" in codes + + # ── pure helpers with edge cases the oracle diff does not reach ───────────── @@ -1874,3 +2430,121 @@ def test_is_pending_is_the_one_definition_shared_with_the_bundle_writer(): # collapsing the two would make a whole `flash_args` mapping read as pending. assert not flash_plan.is_pending({"a": "TBD"}) assert not flash_plan.is_pending(["TBD"]) + + +# -------------------------------------------------------------------------- +# tan-cli#353: an AEN801 slot0 flash could not complete because alp-sdk's +# manifest reports `output_artefact: .../zephyr.elf` while the raw +# `.../zephyr.bin` the mramxip shape needs sits beside it. Measured on real +# silicon (e1m-aen-evk-01, E8 AE822): tan-cli#311's guard refused -- correctly, +# an ELF loadbin'd at slot0_load_address writes its own headers into on-die +# MRAM -- but refused over something resolvable, so no AEN801 flash could +# complete without hand-editing the manifest. +# +# The resolution must NOT weaken #311. These pin both halves. +# -------------------------------------------------------------------------- + + +def _mramxip_inputs(tmp_path, artefact_name): + """A Flow D mramxip FlashInputs: slot0_load_address set (the shape that + reaches the raw-bin guard) plus the ATOC pair it also requires.""" + from tan.core.flash_plan import FlashInputs + + atoc = tmp_path / "AppTocPackage.bin" + atoc.write_bytes(b"\x00" * 32) + return FlashInputs( + core_id="m55_he", + sku="E1M-AEN801", + artefact=str(tmp_path / artefact_name), + flash_args={ + "jlink_flash_device": "AE822FA0E5597LS0_M55_HE", + "slot0_load_address": "0x80010000", + "atoc": str(atoc), + "atoc_address": "0x8057ea50", + }, + ) + + +def test_an_elf_artefact_resolves_to_its_sibling_bin(tmp_path): + """The #353 fix: an ELF with a real sibling `.bin` resolves to it, and the + RESOLVED path is what gets written -- not merely what the guard checked.""" + from tan.core.flash_plan import plan_alif_mram_jlink + + (tmp_path / "zephyr.elf").write_bytes(b"\x7fELF" + b"\x00" * 64) + (tmp_path / "zephyr.bin").write_bytes(b"\x50\x42\x00\x20" + b"\x00" * 64) + + plan = plan_alif_mram_jlink(_mramxip_inputs(tmp_path, "zephyr.elf"), lambda _t: True) + script = plan.jlink_script or "" + assert "zephyr.bin 0x80010000" in script, script + # The whole point: the ELF must never reach loadbin/verifybin. + assert "zephyr.elf" not in script, script + + +def test_an_elf_with_no_sibling_bin_is_still_refused(tmp_path): + """#311 stays strict. No sibling `.bin` -> the refusal stands, because + loadbin'ing the ELF would write its headers into MRAM.""" + from tan.core.flash_plan import FlashPlanError, plan_alif_mram_jlink + + (tmp_path / "zephyr.elf").write_bytes(b"\x7fELF" + b"\x00" * 64) + + with pytest.raises(FlashPlanError) as err: + plan_alif_mram_jlink(_mramxip_inputs(tmp_path, "zephyr.elf"), lambda _t: True) + assert "not a raw .bin" in str(err.value) + assert "No sibling zephyr.bin was found" in str(err.value) + + +def test_a_no_extension_artefact_resolves_to_its_sibling_bin(tmp_path): + """tan-cli#373 (item 4): #367(a) named THREE "plausibly ELF" shapes -- no + extension, `.elf`, `.out` -- but `is_elf_artefact` only ever implemented + the first of the three, silently narrowing #367's own decision. A + toolchain that names its ELF output bare (`app`, no suffix) must resolve + to its same-stem sibling `.bin` exactly like `zephyr.elf` does.""" + from tan.core.flash_plan import plan_alif_mram_jlink + + (tmp_path / "app").write_bytes(b"\x7fELF" + b"\x00" * 64) + (tmp_path / "app.bin").write_bytes(b"\x50\x42\x00\x20" + b"\x00" * 64) + + plan = plan_alif_mram_jlink(_mramxip_inputs(tmp_path, "app"), lambda _t: True) + script = plan.jlink_script or "" + assert "app.bin 0x80010000" in script, script + + +def test_an_out_artefact_resolves_to_its_sibling_bin(tmp_path): + """tan-cli#373 (item 4): the second of #367(a)'s three named shapes -- + `.out` -- must also resolve, not just `.elf`.""" + from tan.core.flash_plan import plan_alif_mram_jlink + + (tmp_path / "zephyr.out").write_bytes(b"\x7fELF" + b"\x00" * 64) + (tmp_path / "zephyr.bin").write_bytes(b"\x50\x42\x00\x20" + b"\x00" * 64) + + plan = plan_alif_mram_jlink(_mramxip_inputs(tmp_path, "zephyr.out"), lambda _t: True) + script = plan.jlink_script or "" + assert "zephyr.bin 0x80010000" in script, script + assert "zephyr.out" not in script, script + + +def test_a_hex_artefact_is_refused_even_with_a_sibling_bin(tmp_path): + """#367: a `.hex` is NOT a plausibly-ELF-with-a-known-sibling case. The + resolution (`flash_plan.resolve_slot0_binary`) is deliberately narrow -- + a plausibly-ELF artefact's (no extension, `.elf`, `.out` -- tan-cli#373 + widened this from `.elf`-only) same-stem `.bin`, the Zephyr build's + known-good pair -- and a `.hex` carries its own load addresses; silently + swapping in an unrelated same-stem `.bin` would flash a DIFFERENT + artefact than the manifest named. #353's decision (a): refuse, never + resolve. (This test used to assert the opposite of both its own name + and its own docstring -- its body proved resolution while its + title/intro promised a refusal; #367 caught the contradiction.)""" + from tan.core.flash_plan import FlashPlanError, plan_alif_mram_jlink + + (tmp_path / "zephyr.hex").write_text(":00000001FF\n", encoding="utf-8") + (tmp_path / "zephyr.bin").write_bytes(b"\x50\x42\x00\x20" + b"\x00" * 64) + + with pytest.raises(FlashPlanError) as raised: + plan_alif_mram_jlink(_mramxip_inputs(tmp_path, "zephyr.hex"), lambda _t: True) + message = str(raised.value) + assert "not a raw .bin" in message + assert "zephyr.hex" in message + assert "slot0_load_address" in message + # Proves this is the "wrong shape" refusal, not the "ELF with no sibling" + # one -- a sibling .bin DOES exist here, and it must still not be used. + assert "Only a plausibly-ELF artefact's" in message diff --git a/python/tests/commands/test_inspect_command.py b/python/tests/commands/test_inspect_command.py new file mode 100644 index 00000000..a335453c --- /dev/null +++ b/python/tests/commands/test_inspect_command.py @@ -0,0 +1,281 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan inspect` -- port of `crates/tan-cli/src/commands/inspect.rs`. + +Every shape asserted below was measured against a freshly-built oracle +(`cargo build -p alp-tan-cli --bin tan` from THIS worktree's `crates/`, not +the possibly-stale `dev`-branch binary -- see `inspect_cmd`'s module +docstring for why that distinction matters here specifically). + +`inspect`/`trace`/`support-bundle` are not yet registered in `tan.cli.app` +(that registration is the orchestrator's, not this unit's, to make -- see +`deferred_cmd.py`'s module docstring), so these tests build a throwaway +local Typer app around the ported command function directly, with a minimal +root callback that reproduces `tan.cli.root`'s `ctx.obj = {"format": ...}` +wiring closely enough to exercise the leading-`--format` path. +""" +from __future__ import annotations + +import json + +import typer +from typer.testing import CliRunner + +from tan.commands.inspect_cmd import ( + ResolvedDebugContext, + collect_resolved_values, + filter_resolved_values, + inspect, + resolve_debug_project_context, +) +from tan.envelope import Project, SdkInfo + + +def _local_app(): + """A throwaway Typer app wrapping just [`inspect`], with a minimal root + callback reproducing `tan.cli.root`'s `ctx.obj = {"format": ...}` wiring -- + `inspect` is not yet registered in the real `tan.cli.app` (that + registration is the orchestrator's to make, not this unit's; see + `deferred_cmd.py`'s module docstring), so testing through it would still + exercise the OLD deferred stub.""" + local = typer.Typer() + + @local.callback(invoke_without_command=True) + def root(ctx: typer.Context, output_format: str = typer.Option(None, "--format")) -> None: + ctx.obj = {"format": output_format} + + local.command("inspect")(inspect) + return local + + +app = _local_app() +runner = CliRunner() + + +def write(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8", newline="") + + +def sdk_at(root): + write(root / "scripts" / "alp_project.py", "# stub") + + +# --------------------------------------------------------------------------- +# The pure model -- collect_resolved_values / filter_resolved_values +# --------------------------------------------------------------------------- + + +def _context(**overrides) -> ResolvedDebugContext: + defaults = dict( + workspace_root="/work/proj", + sdk_root=None, + sdk_tier="none", + board_yaml_path="/work/proj/board.yaml", + board_yaml_exists=False, + west_cwd="/work/proj", + python_binary="python3", + project=Project.resolved("/work/proj", "/work/proj/board.yaml"), + sdk=None, + ) + defaults.update(overrides) + return ResolvedDebugContext(**defaults) + + +def test_six_rows_in_oracle_order_with_unresolved_sdk(): + values = collect_resolved_values(_context()) + assert [v["key"] for v in values] == [ + "workspaceRoot", + "sdkRoot", + "boardYamlPath", + "boardYamlExists", + "westCwd", + "pythonBinary", + ] + sdk_row = next(v for v in values if v["key"] == "sdkRoot") + assert sdk_row["value"] is None + assert sdk_row["source"] == "unresolved" + assert "--sdk-root" in sdk_row["detail"] and "tan sdk switch" in sdk_row["detail"] + + +def test_resolved_sdk_row_reports_workspace_source(): + values = collect_resolved_values( + _context(sdk_root="/work/alp-sdk", sdk=SdkInfo("/work/alp-sdk", "sdkRootFlag")) + ) + sdk_row = next(v for v in values if v["key"] == "sdkRoot") + assert sdk_row == { + "key": "sdkRoot", + "value": "/work/alp-sdk", + "source": "workspace", + "detail": "Resolved alp-sdk root used for scripts and schemas.", + } + + +def test_board_yaml_exists_flips_source_detail_only(): + missing = collect_resolved_values(_context(board_yaml_exists=False)) + present = collect_resolved_values(_context(board_yaml_exists=True)) + m = next(v for v in missing if v["key"] == "boardYamlExists") + p = next(v for v in present if v["key"] == "boardYamlExists") + assert m["value"] is False and "missing" in m["detail"] + assert p["value"] is True and "exists" in p["detail"] + assert m["source"] == p["source"] == "runtime" + + +def test_west_cwd_and_python_binary_are_always_setting_and_default(): + """No `--west-cwd`/`--python-path` flag exists on this CLI -- both rows + always report the always-populated sources, never `unresolved`.""" + values = collect_resolved_values(_context()) + west = next(v for v in values if v["key"] == "westCwd") + py = next(v for v in values if v["key"] == "pythonBinary") + assert west["source"] == "setting" + assert west["value"] == "/work/proj" + assert py["source"] == "default" + + +def test_filter_matches_exact_dotted_and_bracketed_keys(): + values = [ + {"key": "a", "value": 1}, + {"key": "a.b", "value": 2}, + {"key": "a[0]", "value": 3}, + {"key": "ab", "value": 4}, + ] + assert [v["key"] for v in filter_resolved_values(values, "a")] == ["a", "a.b", "a[0]"] + assert filter_resolved_values(values, None) == values + assert filter_resolved_values(values, "nomatch") == [] + + +# --------------------------------------------------------------------------- +# resolve_debug_project_context +# --------------------------------------------------------------------------- + + +def test_context_resolution_posix_paths_and_absolute_board_yaml(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "som:\n sku: E1M-X\n") + ctx = resolve_debug_project_context(None, None, None) + assert ctx.workspace_root == str(tmp_path).replace("\\", "/") + assert ctx.board_yaml_path == f"{ctx.workspace_root}/board.yaml" + assert ctx.board_yaml_exists is True + assert ctx.west_cwd == ctx.workspace_root + assert ctx.sdk_root is None + assert ctx.sdk is None + + # An explicit absolute --board-yaml is reported as given, not re-joined. + elsewhere = tmp_path / "elsewhere.yaml" + write(elsewhere, "x") + ctx2 = resolve_debug_project_context(None, str(elsewhere), None) + assert ctx2.board_yaml_path == str(elsewhere).replace("\\", "/") + + +def test_context_resolves_sdk_root_via_the_narrow_ladder(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + ctx = resolve_debug_project_context(None, None, str(sdk)) + assert ctx.sdk_root == str(sdk).replace("\\", "/") + assert ctx.sdk_tier == "sdkRootFlag" + assert ctx.sdk == SdkInfo(ctx.sdk_root, "sdkRootFlag") + + +# --------------------------------------------------------------------------- +# End to end +# --------------------------------------------------------------------------- + + +def test_json_envelope_reports_all_six_values_and_no_issues(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "som:\n sku: E1M-X\n") + result = runner.invoke(app, ["inspect", "--format", "json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["command"] == "inspect" + assert doc["ok"] is True + assert doc["exitCode"] == 0 + assert doc["project"]["boardYaml"] is not None + assert "sdk" not in doc # nothing resolved + assert len(doc["data"]["resolvedValues"]) == 6 + assert doc["issues"] == [] + + +def test_missing_board_yaml_is_a_warning_not_a_failure(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["inspect", "--format", "json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["ok"] is True + assert doc["project"]["boardYaml"] is None + assert doc["issues"] == [ + { + "code": "inspect.board-yaml-missing", + "severity": "warning", + "message": "board.yaml path could not be resolved or the file does not exist.", + } + ] + + +def test_path_filter_narrows_and_warns_when_nothing_matches(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "x") + ok = runner.invoke(app, ["inspect", "--path", "sdkRoot", "--format", "json"]) + doc = json.loads(ok.stdout) + assert [v["key"] for v in doc["data"]["resolvedValues"]] == ["sdkRoot"] + assert doc["issues"] == [] + + empty = runner.invoke(app, ["inspect", "--path", "bogus.path", "--format", "json"]) + doc2 = json.loads(empty.stdout) + assert doc2["data"]["resolvedValues"] == [] + assert doc2["issues"] == [ + { + "code": "inspect.path-not-found", + "severity": "warning", + "message": "No resolved values match --path 'bogus.path'.", + } + ] + # Still exit 0 -- inspect has no failure exit in the oracle. + assert empty.exit_code == 0 + + +def test_text_mode_writes_nothing_to_stdout_and_a_count_line_to_stderr(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "x") + result = runner.invoke(app, ["inspect"]) + assert result.exit_code == 0 + assert result.stdout == "" + assert "inspect: resolved values=6" in result.stderr + + +def test_quiet_suppresses_per_value_lines(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["inspect", "--quiet"]) + assert "inspect: resolved values=" in result.stderr + assert "workspaceRoot=" not in result.stderr + + +def test_show_origin_adds_source_and_detail_to_text_lines(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["inspect", "--show-origin"]) + assert "source=workspace" in result.stderr + assert "detail=" in result.stderr + + +def test_leading_format_json_before_the_subcommand_reaches_the_command(tmp_path, monkeypatch): + """clap makes `--format` global; `tan --format json inspect` must reach the + envelope path, not a Click usage error.""" + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["--format", "json", "inspect"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["command"] == "inspect" + + +def test_hidden_global_flags_are_accepted_without_error(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke( + app, ["inspect", "--verbose", "--no-color", "--non-interactive", "--ci", "--all"] + ) + assert result.exit_code == 0 + + +def test_a_bad_format_is_a_usage_error_not_a_traceback(): + result = runner.invoke(app, ["inspect", "--format", "yaml"]) + assert result.exit_code == 2 + assert "Traceback" not in result.output diff --git a/python/tests/commands/test_monitor_command.py b/python/tests/commands/test_monitor_command.py index 96bdf891..e37036b3 100644 --- a/python/tests/commands/test_monitor_command.py +++ b/python/tests/commands/test_monitor_command.py @@ -18,23 +18,34 @@ file cannot prove is that a REAL board's bytes make it to the terminal; that needs a bench with a device on it. -**pyserial may or may not be installed, and both shapes are legitimate.** -`ci.yml` installs `-e ./python` with NO extras on purpose -- that is the shape -a customer's `pip install alp-tan` gives, and the only one in which -`tests/gates/test_declared_dependencies.py` can catch an extras-only import -escaping to module scope -- while `python-binaries.yml` and `parity.yml` -install `[monitor]`. So the cases that need pyserial for real carry -`@needs_pyserial` and SKIP in the extras-less shape rather than fail; the ones -that assert the pyserial-ABSENT behaviour are deliberately NOT gated, since -that behaviour is the whole point of them and they simulate the absence -themselves. Adding the extra to `ci.yml` to make the first group run is the -wrong repair: it would blind the dependency gate. +**pyserial may or may not be genuinely installed, and this file does not need +to know which (tan-cli#255).** `ci.yml` installs `-e ./python` with NO extras +on purpose -- that is the shape a customer's `pip install alp-tan` gives, and +the only one in which `tests/gates/test_declared_dependencies.py` can catch an +extras-only import escaping to a top-level-module import -- while `python-binaries.yml` and +`parity.yml` install `[monitor]`. The six cases below that exercise +`_run_monitor`'s real refusal/spawn logic used to SKIP outright in the +extras-less shape (`@needs_pyserial`), which silently dropped exactly the +coverage they were written for on the one install shape `ci.yml` actually +runs. `_stub_pyserial_if_absent()` replaces that: it plants an empty `serial` +module in `sys.modules` when the real one is not importable, so +`_run_monitor`'s precheck (a bare, function-local `import serial`) succeeds +either way. That is safe, not a fake pass, because every test that calls it +also replaces `_available_ports` with a canned list before `_run_monitor` ever +reaches pyserial's actual API -- a placeholder module with no attributes is +indistinguishable from the real one to the code under test. Only the tests +that assert the pyserial-ABSENT behaviour still force the real `ImportError` +themselves (`_block_pyserial`), since producing that failure honestly is the +whole point of them. Installing the extra in `ci.yml` instead was considered +and rejected: it would blind `test_declared_dependencies.py` to the shape a +bare `pip install alp-tan` actually produces. """ from __future__ import annotations import importlib.util import json import sys +import types from pathlib import Path import pytest @@ -49,14 +60,17 @@ runner = CliRunner() -#: `_run_monitor`'s precheck imports `serial` in-process whenever the spawn -#: would be THIS interpreter, and `_available_ports` imports it unconditionally, -#: so every case that gets past either one needs pyserial genuinely importable. -#: Monkeypatching `_available_ports` is not enough -- the precheck runs first. -needs_pyserial = pytest.mark.skipif( - importlib.util.find_spec("serial") is None, - reason="pyserial absent: the optional `monitor` extra is not installed", -) + +def _stub_pyserial_if_absent(monkeypatch) -> None: + """Make a bare `import serial` succeed even when pyserial genuinely is not + installed, so the test calling this exercises `_run_monitor`'s real logic + in every environment `ci.yml` runs in -- not only the `[monitor]` extras + shape. `_run_monitor`'s precheck imports `serial` in-process whenever the + spawn would be THIS interpreter, and does nothing more with it (the actual + port-listing call, `_available_ports`, is always monkeypatched away by the + caller before this matters), so a bare placeholder module satisfies it.""" + if importlib.util.find_spec("serial") is None: + monkeypatch.setitem(sys.modules, "serial", types.ModuleType("serial")) @pytest.fixture(autouse=True) @@ -79,8 +93,8 @@ def envelope(result): return json.loads(result.stdout) -@needs_pyserial def test_no_port_given_lists_available_ports_and_refuses(monkeypatch): + _stub_pyserial_if_absent(monkeypatch) monkeypatch.setattr( monitor_cmd, "_available_ports", lambda: [("COM7", "USB Serial"), ("COM8", "")] ) @@ -97,8 +111,8 @@ def test_no_port_given_lists_available_ports_and_refuses(monkeypatch): ] -@needs_pyserial def test_no_port_given_and_none_detected_says_so(monkeypatch): + _stub_pyserial_if_absent(monkeypatch) monkeypatch.setattr(monitor_cmd, "_available_ports", lambda: []) result = runner.invoke(app, ["--format", "json"]) assert result.exit_code == 1 @@ -107,8 +121,8 @@ def test_no_port_given_and_none_detected_says_so(monkeypatch): assert doc["data"]["availablePorts"] == [] -@needs_pyserial def test_port_not_in_the_detected_list_refuses(monkeypatch): + _stub_pyserial_if_absent(monkeypatch) monkeypatch.setattr(monitor_cmd, "_available_ports", lambda: [("COM7", "")]) result = runner.invoke(app, ["--port", "COM9", "--format", "json"]) assert result.exit_code == 1 @@ -117,8 +131,8 @@ def test_port_not_in_the_detected_list_refuses(monkeypatch): assert "'COM9' not found" in doc["issues"][0]["message"] -@needs_pyserial def test_a_present_port_spawns_miniterm_and_reports_success(monkeypatch): + _stub_pyserial_if_absent(monkeypatch) monkeypatch.setattr(monitor_cmd, "_available_ports", lambda: [("COM7", "")]) captured = {} @@ -173,11 +187,11 @@ def fake_run(argv, **kwargs): assert captured["argv"][0] != sys.executable -@needs_pyserial def test_a_nonzero_miniterm_exit_maps_to_runtime_failure_not_the_raw_code(monkeypatch): """Mirrors the shipped Rust forwarder's `s.code().unwrap_or(1)` -> `ExitCode::RuntimeFailure` mapping -- NOT the oracle's literal `raise SystemExit(rc)`.""" + _stub_pyserial_if_absent(monkeypatch) monkeypatch.setattr(monitor_cmd, "_available_ports", lambda: [("COM7", "")]) captured = {} @@ -200,11 +214,11 @@ def fake_run(argv, **kwargs): assert captured["argv"][0] == sys.executable -@needs_pyserial def test_default_baud_is_the_sdk_wide_console_default(monkeypatch): """`--baud` omitted must fall back to `DEFAULT_BAUD` (115200), matching the oracle's `monitor.py::DEFAULT_BAUD` -- a silent drift here garbles every console session on the bench.""" + _stub_pyserial_if_absent(monkeypatch) monkeypatch.setattr(monitor_cmd, "_available_ports", lambda: [("COM7", "")]) captured = {} @@ -255,6 +269,50 @@ def test_a_bad_format_value_is_a_usage_error_not_a_traceback(): assert "Traceback" not in (result.output or "") +@pytest.mark.parametrize( + "flag", + [ + ["--project", "."], + ["--board-yaml", "board.yaml"], + ["--sdk-root", "."], + ["--target", "zephyr-conf"], + ["--all"], + ["--verbose"], + ["--quiet"], + ["--no-color"], + ["--non-interactive"], + ["--ci"], + ], +) +def test_the_globals_the_oracle_ignores_are_accepted_not_rejected(flag, monkeypatch): + """tan-cli#255: the oracle's clap `GlobalArgs` are declared on EVERY verb, + `monitor` included, and never read by it (confirmed live against + `tan.exe monitor`: each flag alone reaches the identical port-resolution + failure a bare `tan.exe monitor --port COM7` does). Without them declared + here, `tan monitor --sdk-root --port COM7` was a Click "No such + option" usage error at exit 2 where the oracle exits 0/1 -- so a caller + forwarding the global set unconditionally (the extension, a saved script) + could never open a console.""" + _stub_pyserial_if_absent(monkeypatch) + monkeypatch.setattr(monitor_cmd, "_available_ports", lambda: [("COM7", "")]) + + class _Completed: + returncode = 0 + + monkeypatch.setattr( + monitor_cmd.subprocess, "run", lambda argv, **kwargs: _Completed() + ) + + result = runner.invoke(app, ["--port", "COM7", *flag, "--format", "json"]) + assert result.exit_code == 0, result.output + assert envelope(result)["command"] == "monitor" + + +def test_an_unknown_flag_is_still_a_usage_error(): + """The accepted-globals list above must not turn into "accept anything".""" + assert runner.invoke(app, ["--not-a-real-flag"]).exit_code == 2 + + def _block_pyserial(monkeypatch): """Make `from serial.tools import list_ports` raise ImportError. diff --git a/python/tests/commands/test_native_sim_e2e.py b/python/tests/commands/test_native_sim_e2e.py index 31a613eb..b34ac5a4 100644 --- a/python/tests/commands/test_native_sim_e2e.py +++ b/python/tests/commands/test_native_sim_e2e.py @@ -91,7 +91,13 @@ def _zephyr_base() -> Path | None: - raw = os.environ.get("ZEPHYR_BASE") + # From `REAL_ENVIRON` (captured at collection time in `tests/conftest.py`), + # not `os.environ` read here -- the autouse `_scrub_sdk_discovery_env` + # fixture now deletes `ZEPHYR_BASE` from the process environment ahead of + # every test function, so an `os.environ` read from inside this module's + # test body/helpers (called after that fixture has run) would always see + # it gone, even on a host where a real Zephyr workspace is exported. + raw = REAL_ENVIRON.get("ZEPHYR_BASE") if not raw: return None base = Path(raw) diff --git a/python/tests/commands/test_new_som_command.py b/python/tests/commands/test_new_som_command.py index bbe601f4..d8b8a9bf 100644 --- a/python/tests/commands/test_new_som_command.py +++ b/python/tests/commands/test_new_som_command.py @@ -250,6 +250,12 @@ def test_hw_rev_cross_checked_against_real_family_file(tmp_path): def test_sdk_root_unresolved_fails_loud(tmp_path): + """Exit code 2 (VALIDATION_FAILURE), not the flat 1 every other new-som + failure uses: this is the ONE failure the port adds that the alp_cli + original never had (it always ran from within a checkout), and it + mirrors the Rust forwarder's own preflight + (`sdk_cli.rs::run`) -- confirmed live: `tan.exe new-som --sdk-root ` + exits 2.""" result = runner.invoke( app, [ @@ -264,7 +270,7 @@ def test_sdk_root_unresolved_fails_loud(tmp_path): "fam", ], ) - assert result.exit_code == 1 + assert result.exit_code == 2 assert "alp-sdk root is unresolved" in result.output @@ -334,6 +340,40 @@ def test_output_root_pointing_at_a_regular_file_is_a_usage_error(tmp_path): assert "is a file" in result.output +def test_full_global_flag_set_is_accepted_even_when_meaningless(tmp_path): + """The oracle's clap `GlobalArgs` are `global = true`, so `new-som` + accepts `--board-yaml`/`--target`/`--all`/`--verbose`/`--quiet`/ + `--no-color`/`--non-interactive`/`--ci`/`--format` even though it never + reads any of them -- confirmed live: `tan.exe new-som --board-yaml x + --target t --all --verbose --quiet --no-color --non-interactive --ci + --format json --sdk-root ` still reaches the SDK-root-unresolved + failure, not a parse error. Regression for the Click "No such option" + usage error (exit 2) this port used to raise for each of these instead + (tan-cli#254).""" + result = runner.invoke( + app, + [ + "--board-yaml", "x.yaml", + "--target", "zephyr-conf", + "--all", + "--verbose", + "--quiet", + "--no-color", + "--non-interactive", + "--ci", + "--format", "json", + "--dry-run", + "--sdk-root", str(_SDK_ROOT), + "--output-root", str(tmp_path), + "--sku", "E1M-XTST6", + "--soc-ref", "test:testfam:testpart6", + "--family", "test-fam", + "--default-board", "E1M-EVK", + ], + ) + assert result.exit_code == 0, result.output + + # --------------------------------------------------------------------------- # Successful scaffold: dry-run and real write, in a scratch --output-root # --------------------------------------------------------------------------- @@ -470,6 +510,71 @@ def test_cores_option_produces_exactly_the_given_topology_keys(tmp_path): assert "topology:\n core_a: {}\n core_b: {}\n" in preset_text +# --------------------------------------------------------------------------- +# Acceptance (tan-cli#254): a new SoC/vendor onboards with NO tan release. +# --------------------------------------------------------------------------- + + +def test_new_vendor_onboards_through_metadata_alone_with_no_tan_release(tmp_path): + """The whole point of the metadata-driven porting kit: a vendor/SoC `tan` + has never heard of scaffolds and validates through `new-som` + the SDK's + schemas alone -- no `tan` source change, and so no `tan` release, is + needed to onboard it. + + Proven two ways, not just exercised: + + 1. A vendor/family/part triple invented FOR THIS TEST is asserted absent + from `tan`'s own source tree first -- if onboarding this vendor needed + special-casing, that string would already have to be there for the + assertion below to hold, and it is not. (This is the same shape as + `tests/gates/test_no_new_hardware_facts.py`'s allowlist gate, run here + against one concrete, never-before-seen vendor rather than the fixed + patterns that gate already knows to look for.) + 2. The scaffold this genuinely-new vendor produces validates against the + REAL `som-preset-v1`/`soc-spec-v1` schemas end to end (dry-run AND a + real write), the same as every other SKU in this file -- proving the + whole `new-som` -> schema-validate -> `pr-metadata-validate` pipeline + needs nothing vendor-specific to accept it. + """ + novel_vendor, novel_family, novel_part = "quixotic", "novaspark", "ns1" + tan_src = Path(__file__).resolve().parents[2] / "tan" + hits = [ + p + for p in tan_src.rglob("*.py") + if novel_vendor in p.read_text(encoding="utf-8", errors="replace") + ] + assert not hits, ( + f"{novel_vendor!r} already appears in {hits} -- this proves nothing about " + "onboarding a genuinely new vendor; pick a different invented slug" + ) + + soc_ref = f"{novel_vendor}:{novel_family}:{novel_part}" + common = [ + "--sdk-root", str(_SDK_ROOT), + "--sku", "E1M-QUIX1", + "--soc-ref", soc_ref, + "--family", f"{novel_vendor}-{novel_family}", + "--default-board", "E1M-EVK", + ] + + dry = runner.invoke(app, ["--dry-run", "--output-root", str(tmp_path / "dry"), *common]) + assert dry.exit_code == 0, dry.output + assert "Preset skeleton validates against som-preset-v1" in dry.output + assert "SoC spec skeleton validates against soc-spec-v1" in dry.output + + written = runner.invoke(app, ["--output-root", str(tmp_path / "written"), *common]) + assert written.exit_code == 0, written.output + preset_path = tmp_path / "written" / "metadata" / "e1m_modules" / "E1M-QUIX1.yaml" + soc_path = ( + tmp_path / "written" / "metadata" / "socs" / novel_vendor / novel_family + / f"{novel_part}.json" + ) + assert preset_path.is_file() + assert soc_path.is_file() + assert f"silicon: {soc_ref}" in preset_path.read_text(encoding="utf-8") + assert json.loads(soc_path.read_text(encoding="utf-8"))["vendor"] == novel_vendor + + def test_interactive_prompts_ask_same_questions_in_order(monkeypatch, tmp_path): """The `questionary` -> `click.prompt`/`click.Choice` swap (module docstring) is the riskiest divergence in the port; lock the same diff --git a/python/tests/commands/test_pinmux_command.py b/python/tests/commands/test_pinmux_command.py new file mode 100644 index 00000000..35d3d2ff --- /dev/null +++ b/python/tests/commands/test_pinmux_command.py @@ -0,0 +1,636 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan pinmux` -- CLI surface tests. + +`pinmux` is not registered in `tan.cli.app` by this change (the shared +`cli.py` registration point is owned by the orchestrator wiring commands in +parallel), so these tests mount the command on a throwaway `typer.Typer()` +rather than importing `tan.cli.app`, matching +`test_faultdecode_command.py`/`test_kconfig_command.py`'s own note for the +same situation. + +Every wire-shape assertion below (issue codes, `data`/`sdk` envelope shape, +the `--family` overriding `--sku` without evaluating it, the "no +`project-pin-unresolved` warning" behaviour) was measured directly against +`target/debug/tan.exe` (tan 0.4.1-dev), including a full byte-for-byte diff of +the real `metadata/pinmux/aen.yaml` (96 pads) table against a live alp-sdk +checkout where one was reachable. The `metadata/pinmux/v2n.yaml` table in the +real checkout is (at the time of writing) entirely `e1m_pad: "TBD"` rows, so +`pinmux.table-empty` is exercised here with a small SYNTHETIC table rather +than depending on that fact staying true. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import typer +from typer.testing import CliRunner + +from tan.commands.pinmux_cmd import ( + PinmuxParseError, + parse_pinmux_table_checked, + pinmux_family_for_sku, +) +from tan.commands.pinmux_cmd import pinmux as pinmux_command + +app = typer.Typer() +app.command("pinmux")(pinmux_command) + +runner = CliRunner() + +#: `target/{release,debug}/tan(.exe)` next to this checkout -- the same +#: discovery `tests/parity/oracle.py`'s `rust_binary()` uses, kept +#: independent here rather than imported so this file's only non-stdlib +#: dependency stays `tan.commands.pinmux_cmd` (matching every other test file +#: under `tests/commands/`). `TAN_RUST_BINARY` overrides, same env var. +_EXE = ".exe" if sys.platform == "win32" else "" +_REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _oracle_binary() -> str | None: + override = os.environ.get("TAN_RUST_BINARY") + if override: + return override + for profile in ("release", "debug"): + candidate = _REPO_ROOT / "target" / profile / f"tan{_EXE}" + if candidate.exists(): + return str(candidate) + return None + + +_ORACLE = _oracle_binary() +_ORACLE_REQUIRED = pytest.mark.skipif( + _ORACLE is None, + reason="needs a built Rust tan (cargo build --bin tan) to measure the divergence", +) + + +def _run_oracle(argv: list[str], cwd: Path) -> tuple[int, dict]: + proc = subprocess.run( + [_ORACLE, *argv], capture_output=True, text=True, encoding="utf-8", cwd=cwd + ) + return proc.returncode, json.loads(proc.stdout) + +_SAMPLE_TABLE = """\ +schemaVersion: pinmux-capability-v1 +family: aen +display_name: "E1M-AEN (Alif Ensemble)" +pads: + - { e1m_pad: "A3", e1m_function: "PWM6", owner: "alif", silicon_peripheral: "UT3_T1_C", silicon_pad: "P10_7" } + - { e1m_pad: "A15", e1m_function: "ANA_S0", owner: "alif", silicon_peripheral: "", silicon_pad: "P0_0" } +""" + + +def _sdk_root(tmp_path: Path, pinmux_yaml: dict[str, str]) -> Path: + sdk = tmp_path / "sdk" + (sdk / "scripts").mkdir(parents=True) + (sdk / "scripts" / "alp_project.py").write_text("", encoding="utf-8") + pinmux_dir = sdk / "metadata" / "pinmux" + pinmux_dir.mkdir(parents=True) + for family, text in pinmux_yaml.items(): + (pinmux_dir / f"{family}.yaml").write_text(text, encoding="utf-8") + return sdk + + +def _project(tmp_path: Path) -> Path: + proj = tmp_path / "proj" + proj.mkdir() + return proj + + +# --------------------------------------------------------------------------- +# CLI surface +# --------------------------------------------------------------------------- + + +def test_help_lists_sku_and_family() -> None: + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "--sku" in result.output + assert "--family" in result.output + + +def test_no_target_is_a_warning_at_exit_zero(tmp_path: Path) -> None: + # `--sdk-root` given so ONLY the no-target branch fires -- an unresolved + # SDK independently pushes its own `pinmux.sdk-root-unresolved` issue + # (measured against the oracle: neither branch suppresses the other), and + # this test pins the no-target case in isolation. + sdk = _sdk_root(tmp_path, {}) + proj = _project(tmp_path) + result = runner.invoke( + app, ["--project", str(proj), "--sdk-root", str(sdk), "--format", "json"] + ) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["ok"] is True + assert envelope["data"]["family"] is None + assert envelope["data"]["pads"] == [] + assert envelope["issues"] == [ + { + "code": "pinmux.no-target", + "severity": "warning", + "message": "Provide --sku or --family .", + } + ] + + +def test_no_target_and_unresolved_sdk_both_report(tmp_path: Path) -> None: + """Measured against the oracle: the two independent guards do not + suppress each other -- both `pinmux.no-target` and + `pinmux.sdk-root-unresolved` appear when neither a target nor an SDK + resolves.""" + proj = _project(tmp_path) + result = runner.invoke(app, ["--project", str(proj), "--format", "json"]) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert "sdk" not in envelope # absent, not null -- nothing resolved + codes = {issue["code"] for issue in envelope["issues"]} + assert codes == {"pinmux.no-target", "pinmux.sdk-root-unresolved"} + + +def test_unresolved_sdk_root_is_a_warning_family_still_resolves(tmp_path: Path) -> None: + proj = _project(tmp_path) + result = runner.invoke( + app, ["--project", str(proj), "--sku", "E1M-AEN801", "--format", "json"] + ) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert "sdk" not in envelope + assert envelope["data"]["sdkRoot"] is None + assert envelope["data"]["sku"] == "E1M-AEN801" + assert envelope["data"]["family"] == "aen" # resolved from the SKU regardless + assert envelope["issues"][0]["code"] == "pinmux.sdk-root-unresolved" + + +def test_unknown_sku_is_a_warning(tmp_path: Path) -> None: + sdk = _sdk_root(tmp_path, {}) + proj = _project(tmp_path) + result = runner.invoke( + app, + [ + "--project", str(proj), + "--sku", "E1M-BOGUS", + "--sdk-root", str(sdk), + "--format", "json", + ], + ) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["data"]["family"] is None + assert envelope["issues"][0]["code"] == "pinmux.unknown-sku" + + +def test_family_overrides_sku_without_evaluating_it(tmp_path: Path) -> None: + """Measured against the oracle: `--sku E1M-BOGUS --family aen` reports + family "aen" with NO `pinmux.unknown-sku` issue -- `--family` short- + circuits before the SKU is ever looked up.""" + sdk = _sdk_root(tmp_path, {"aen": _SAMPLE_TABLE}) + proj = _project(tmp_path) + result = runner.invoke( + app, + [ + "--project", str(proj), + "--sku", "E1M-BOGUS", + "--family", "aen", + "--sdk-root", str(sdk), + "--format", "json", + ], + ) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["data"]["sku"] == "E1M-BOGUS" # still echoed + assert envelope["data"]["family"] == "aen" + assert envelope["issues"] == [] + + +def test_table_not_found_is_a_warning(tmp_path: Path) -> None: + sdk = _sdk_root(tmp_path, {}) + proj = _project(tmp_path) + result = runner.invoke( + app, + [ + "--project", str(proj), + "--family", "bogus", + "--sdk-root", str(sdk), + "--format", "json", + ], + ) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["issues"][0]["code"] == "pinmux.table-not-found" + + +def test_real_table_resolves_family_display_name_and_pads(tmp_path: Path) -> None: + sdk = _sdk_root(tmp_path, {"aen": _SAMPLE_TABLE}) + proj = _project(tmp_path) + result = runner.invoke( + app, + [ + "--project", str(proj), + "--sku", "E1M-AEN801", + "--sdk-root", str(sdk), + "--format", "json", + ], + ) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["sdk"] == {"root": str(sdk).replace("\\", "/"), "sourceTier": "sdkRootFlag"} + assert envelope["data"]["displayName"] == "E1M-AEN (Alif Ensemble)" + assert envelope["data"]["pads"] == [ + { + "e1mPad": "A3", + "e1mFunction": "PWM6", + "owner": "alif", + "siliconPeripheral": "UT3_T1_C", + "siliconPad": "P10_7", + }, + { + "e1mPad": "A15", + "e1mFunction": "ANA_S0", + "owner": "alif", + "siliconPeripheral": "", + "siliconPad": "P0_0", + }, + ] + assert envelope["issues"] == [] + + +def test_non_string_scalar_pad_fields_coerce_instead_of_refusing(tmp_path: Path) -> None: + """BLOCKER regression: `owner`/`silicon_peripheral`/`silicon_pad` used to + hard-refuse (exit 2) any non-`str` PyYAML scalar. Every `PinmuxPad` field + is a `String` on the oracle, which coerces ANY scalar to its own text + instead of rejecting it -- byte-matches the oracle: `owner: 7` -> `"7"`, + `silicon_peripheral: 3.5` -> `"3.5"`, `silicon_pad: true` -> `"true"`, all + at exit 0.""" + table = ( + "schemaVersion: pinmux-capability-v1\nfamily: v2n\npads:\n" + " - { e1m_pad: A1, e1m_function: GPIO, owner: 7, silicon_peripheral: 3.5, " + "silicon_pad: true }\n" + ) + sdk = _sdk_root(tmp_path, {"v2n": table}) + proj = _project(tmp_path) + result = runner.invoke( + app, + ["--project", str(proj), "--family", "v2n", "--sdk-root", str(sdk), "--format", "json"], + ) + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["issues"] == [] + assert envelope["data"]["pads"] == [ + { + "e1mPad": "A1", + "e1mFunction": "GPIO", + "owner": "7", + "siliconPeripheral": "3.5", + "siliconPad": "true", + } + ] + + +def test_compound_pad_fields_still_refuse(tmp_path: Path) -> None: + """The one type mismatch a `String` field can never absorb, unaffected by + the leniency above: `owner: [a, b]` and `e1m_pad: {a: b}` both still exit + 2 on the oracle, and still do here.""" + sequence_owner = ( + "schemaVersion: pinmux-capability-v1\nfamily: v2n\npads:\n" + " - { e1m_pad: A1, e1m_function: GPIO, owner: [a, b], silicon_peripheral: X, " + "silicon_pad: Y }\n" + ) + sdk = _sdk_root(tmp_path, {"v2n": sequence_owner}) + proj = _project(tmp_path) + result = runner.invoke( + app, + ["--project", str(proj), "--family", "v2n", "--sdk-root", str(sdk), "--format", "json"], + ) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["issues"][0]["code"] == "pinmux.schema-version-unsupported" + + mapping_pad = ( + "schemaVersion: pinmux-capability-v1\nfamily: v2n\npads:\n" + " - { e1m_pad: {a: b}, e1m_function: GPIO, owner: x, silicon_peripheral: X, " + "silicon_pad: Y }\n" + ) + (sdk / "metadata" / "pinmux" / "v2n.yaml").write_text(mapping_pad, encoding="utf-8") + result = runner.invoke( + app, + ["--project", str(proj), "--family", "v2n", "--sdk-root", str(sdk), "--format", "json"], + ) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["issues"][0]["code"] == "pinmux.schema-version-unsupported" + + +@_ORACLE_REQUIRED +def test_capitalized_bool_pad_literal_is_a_known_divergence_from_the_oracle( + tmp_path: Path, +) -> None: + """Both sides accept the row (exit 0, one pad). The oracle preserves the + RAW YAML source spelling of a coerced scalar (`owner: True` -> `"True"`, + `silicon_peripheral: on` -> `"on"`, `silicon_pad: yes` -> `"yes"`) -- + there is no equivalent recovery available to this port: PyYAML's stock + (unmodified, per the module docstring) `SafeLoader` has already collapsed + `True`/`On`/`Yes` to a single Python `bool True` by the time `_pad_field` + ever sees it, with no way back to which of those spellings the document + used. `_pad_field` prints the YAML-CANONICAL spelling instead + (`"true"`, lowercase) -- correct for the common case (a lowercase + `true`/`false` in a real generated table), divergent only for a + capitalized or `on`/`off`/`yes`/`no`-style pad value, which no real + `metadata/pinmux/*.yaml` table in this repo has ever contained. + """ + table = ( + "schemaVersion: pinmux-capability-v1\nfamily: aen\npads:\n" + ' - { e1m_pad: "A3", e1m_function: "PWM6", owner: True, ' + "silicon_peripheral: on, silicon_pad: yes }\n" + ) + sdk = _sdk_root(tmp_path, {"aen": table}) + proj = _project(tmp_path) + argv = [ + "--project", str(proj), + "--family", "aen", + "--sdk-root", str(sdk), + "--format", "json", + ] + result = runner.invoke(app, argv) + p_out = json.loads(result.stdout) + r_code, r_out = _run_oracle(["pinmux", *argv], tmp_path) + + assert result.exit_code == r_code == 0 + assert p_out["issues"] == r_out["issues"] == [] + r_pad, p_pad = r_out["data"]["pads"][0], p_out["data"]["pads"][0] + assert r_pad == { + "e1mPad": "A3", + "e1mFunction": "PWM6", + "owner": "True", + "siliconPeripheral": "on", + "siliconPad": "yes", + } + assert p_pad == { + "e1mPad": "A3", + "e1mFunction": "PWM6", + "owner": "true", + "siliconPeripheral": "true", + "siliconPad": "true", + } + # Every OTHER field on the envelope is a real match, not coincidentally + # unchecked. + assert {**r_out, "data": {**r_out["data"], "pads": []}} == { + **p_out, + "data": {**p_out["data"], "pads": []}, + } + + +def test_table_empty_after_tbd_filtering_is_a_validation_failure(tmp_path: Path) -> None: + all_tbd = ( + "schemaVersion: pinmux-capability-v1\nfamily: v2n\npads:\n" + ' - { e1m_pad: "TBD", e1m_function: "TBD", owner: "renesas", ' + 'silicon_peripheral: "X", silicon_pad: "PA2" }\n' + ) + sdk = _sdk_root(tmp_path, {"v2n": all_tbd}) + proj = _project(tmp_path) + result = runner.invoke( + app, + [ + "--project", str(proj), + "--family", "v2n", + "--sdk-root", str(sdk), + "--format", "json", + ], + ) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["ok"] is False + assert envelope["data"]["pads"] == [] + assert envelope["issues"][0]["code"] == "pinmux.table-empty" + + +def test_schema_version_skew_is_a_validation_failure(tmp_path: Path) -> None: + v2_doc = "schemaVersion: pinmux-capability-v2\nfamily: aen\npads: []\n" + sdk = _sdk_root(tmp_path, {"aen": v2_doc}) + proj = _project(tmp_path) + result = runner.invoke( + app, + [ + "--project", str(proj), + "--family", "aen", + "--sdk-root", str(sdk), + "--format", "json", + ], + ) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["issues"][0]["code"] == "pinmux.schema-version-unsupported" + + +def test_text_mode_reports_family_and_pad_count(tmp_path: Path) -> None: + sdk = _sdk_root(tmp_path, {"aen": _SAMPLE_TABLE}) + proj = _project(tmp_path) + result = runner.invoke( + app, + ["--project", str(proj), "--sku", "E1M-AEN801", "--sdk-root", str(sdk)], + ) + assert result.exit_code == 0 + assert "pinmux: family=aen pads=2" in result.output + + +def test_text_mode_no_target_shows_a_dash(tmp_path: Path) -> None: + proj = _project(tmp_path) + result = runner.invoke(app, ["--project", str(proj)]) + assert "pinmux: family=- pads=0" in result.output + + +# --------------------------------------------------------------------------- +# tan-cli#359 -- `--family` may not escape `/metadata/pinmux` +# --------------------------------------------------------------------------- + +#: Every `--family` shape that must be refused BEFORE any read. The Windows +#: rows are exercised on POSIX too, deliberately: `pathlib` on POSIX treats a +#: backslash as an ordinary filename character and `C:` as an ordinary +#: filename, so a guard written against `os.sep` alone passes every one of +#: these on Linux while leaving Windows wide open. The check under test reads +#: the RAW string on every host, so these rows are meaningful everywhere -- +#: which is the whole point of running them here rather than behind a +#: `sys.platform` skip. +_REFUSED_FAMILIES = [ + pytest.param("/etc/passwd", id="posix-absolute"), + pytest.param("../../../etc/passwd", id="posix-traversal"), + pytest.param("..", id="dotdot-component"), + pytest.param(".", id="dot-component"), + pytest.param("C:", id="windows-drive-relative"), + pytest.param("C:aen", id="windows-drive-relative-named"), + pytest.param("C:\\Windows\\aen", id="windows-rooted-drive"), + pytest.param("\\\\server\\share\\aen", id="windows-unc"), + pytest.param("\\aen", id="windows-rooted-no-drive"), + pytest.param("sub\\aen", id="backslash-separator"), + pytest.param("..\\..\\aen", id="backslash-traversal"), + pytest.param("sub/aen", id="slash-separator"), + pytest.param("", id="empty"), +] + + +@pytest.mark.parametrize("family", _REFUSED_FAMILIES) +def test_family_outside_the_pinmux_dir_is_one_coded_refusal(tmp_path: Path, family: str) -> None: + """ONE coded issue, exit 2, no pads -- and, since a real table sits in the + SDK under the ordinary `aen` stem, the empty `pads` also witnesses that + nothing else was read in its place.""" + sdk = _sdk_root(tmp_path, {"aen": _SAMPLE_TABLE}) + proj = _project(tmp_path) + result = runner.invoke( + app, + [ + "--project", str(proj), + "--family", family, + "--sdk-root", str(sdk), + "--format", "json", + ], + ) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["ok"] is False + assert envelope["data"]["pads"] == [] + assert "displayName" not in envelope["data"] + assert [issue["code"] for issue in envelope["issues"]] == ["pinmux.family-invalid"] + + +def test_absolute_family_no_longer_reads_a_second_sdk_checkout(tmp_path: Path) -> None: + """The issue's own reproduction, verbatim (tan-cli#359): with two alp-sdk + checkouts, `--sdk-root --family /metadata/pinmux/aen` used to exit 0 + reporting `sdkRoot: `, `pads: 96`, `issues: []` -- the envelope claiming + A while the table came from B. `Path(A) / "/metadata/pinmux/aen.yaml"` + IS the B path (an absolute join discards the accumulated prefix), which is + exactly what made the sdkRoot field a lie rather than merely unhelpful. + + B's table is a VALID one here, so a regression cannot hide behind a parse + error: if the read happened, `pads` is 2 and `issues` is empty.""" + sdk_a = _sdk_root(tmp_path / "a", {}) + sdk_b = _sdk_root(tmp_path / "b", {"aen": _SAMPLE_TABLE}) + proj = _project(tmp_path) + escaping = str(sdk_b / "metadata" / "pinmux" / "aen") + result = runner.invoke( + app, + [ + "--project", str(proj), + "--sdk-root", str(sdk_a), + "--family", escaping, + "--format", "json", + ], + ) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["data"]["sdkRoot"] == str(sdk_a).replace("\\", "/") + assert envelope["data"]["pads"] == [] + assert [issue["code"] for issue in envelope["issues"]] == ["pinmux.family-invalid"] + + +def test_symlinked_table_escaping_the_pinmux_dir_is_refused(tmp_path: Path) -> None: + """The half the stem check structurally CANNOT see: `aen` is a perfectly + plain stem, and the escape lives in the filesystem instead. This is why + the containment re-check is a second, independent guard rather than a + belt-and-braces duplicate of the charset.""" + sdk = _sdk_root(tmp_path, {}) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "aen.yaml").write_text(_SAMPLE_TABLE, encoding="utf-8") + link = sdk / "metadata" / "pinmux" / "aen.yaml" + try: + os.symlink(outside / "aen.yaml", link) + except (OSError, NotImplementedError) as err: + # Windows needs Developer Mode or SeCreateSymbolicLinkPrivilege; the + # guard is host-independent, so skipping the FIXTURE here costs no + # coverage of the guard itself on such a host. + pytest.skip(f"host cannot create a symlink: {err}") + proj = _project(tmp_path) + result = runner.invoke( + app, + [ + "--project", str(proj), + "--family", "aen", + "--sdk-root", str(sdk), + "--format", "json", + ], + ) + assert result.exit_code == 2 + envelope = json.loads(result.stdout) + assert envelope["data"]["pads"] == [] + assert [issue["code"] for issue in envelope["issues"]] == ["pinmux.family-invalid"] + + +# NON-VACUITY for the three refusals above -- a guard that refused EVERYTHING +# would satisfy every one of them. Deliberately not a fresh unit test of the +# private predicate: `test_family_overrides_sku_without_evaluating_it` +# (`--family aen`, `issues == []`) and +# `test_real_table_resolves_family_display_name_and_pads` (`--sku E1M-AEN801` +# -> the `aen` stem, 2 pads) already assert the accepting side end to end, +# through the same call path, and would both go red the moment the charset +# stopped admitting an ordinary stem. + + +# --------------------------------------------------------------------------- +# Pure-function unit tests (mirrors `crates/tan-core/src/pinmux.rs`'s own +# `#[cfg(test)]` module) +# --------------------------------------------------------------------------- + + +def test_sku_to_family_prefix_map(): + assert pinmux_family_for_sku("E1M-AEN701") == "aen" + assert pinmux_family_for_sku("E1M-V2N44") == "v2n" + # E1M-V2M reuses the base V2N pinout in full; no separate table. + assert pinmux_family_for_sku("E1M-V2M01") == "v2n" + assert pinmux_family_for_sku("E1M-NX93") == "imx93" + assert pinmux_family_for_sku("E1M-UNKNOWN") is None + + +def test_parse_drops_tbd_sentinel_pads(): + table = parse_pinmux_table_checked( + "schemaVersion: pinmux-capability-v1\nfamily: v2n\npads:\n" + ' - { e1m_pad: "TBD", e1m_function: "TBD", owner: "renesas", ' + 'silicon_peripheral: "BL_PWM", silicon_pad: "PA5" }\n' + ' - { e1m_pad: "A3", e1m_function: "PWM6", owner: "alif", ' + 'silicon_peripheral: "", silicon_pad: "P0_0" }\n' + ) + assert len(table.pads) == 1 + assert table.pads[0].e1m_pad == "A3" + + +def test_parse_drops_pads_missing_required_keys_and_defaults_owner(): + table = parse_pinmux_table_checked( + "schemaVersion: pinmux-capability-v1\nfamily: aen\npads:\n" + ' - { e1m_pad: "A3" }\n' + ' - { e1m_pad: "A4", e1m_function: "PWM4" }\n' + ) + assert len(table.pads) == 1 + assert table.pads[0].e1m_function == "PWM4" + assert table.pads[0].owner == "" + + +def test_parse_rejects_non_v1_schema_version(): + try: + parse_pinmux_table_checked("schemaVersion: pinmux-capability-v2\nfamily: aen\npads: []\n") + except PinmuxParseError: + pass + else: + raise AssertionError("expected a PinmuxParseError") + + try: + parse_pinmux_table_checked( + 'family: aen\npads:\n - { e1m_pad: "A3", e1m_function: "PWM6", owner: "alif", ' + 'silicon_peripheral: "", silicon_pad: "P0_0" }\n' + ) + except PinmuxParseError: + pass + else: + raise AssertionError("expected a PinmuxParseError for a missing schemaVersion") + + +def test_parse_fails_soft_on_malformed_yaml_as_a_document_error(): + try: + parse_pinmux_table_checked(": : not yaml : :") + except PinmuxParseError: + pass + else: + raise AssertionError("expected a PinmuxParseError") diff --git a/python/tests/commands/test_renode_command.py b/python/tests/commands/test_renode_command.py index 105256fc..9a220100 100644 --- a/python/tests/commands/test_renode_command.py +++ b/python/tests/commands/test_renode_command.py @@ -94,7 +94,16 @@ def _write_fake_renode( body += f"print({line!r})\n" body += f"sys.exit({exit_code})\n" impl.write_text(body, encoding="utf-8") + _write_renode_wrapper(bin_dir, impl) + +def _write_renode_wrapper(bin_dir: Path, impl: Path) -> None: + """The `renode`/`renode.cmd` launcher shim shared by every fake-binary + helper in this file: shells out to THIS SAME Python interpreter via its + full `sys.executable` path rather than an external tool resolved + through PATH, because `run_renode_cmd` overrides the child's PATH to + just the fake bin dir (`path_override`) -- a bare external command would + fail to resolve there.""" python = sys.executable if os.name == "nt": script = bin_dir / "renode.cmd" @@ -107,6 +116,76 @@ def _write_fake_renode( os.chmod(script, 0o755) +def _write_fake_sim_renode( + bin_dir: Path, + *, + preamble: list[str] | None = None, + exit_after_s: float | None = None, + exit_code: int = 0, +) -> None: + """A fake `renode` on PATH for `--sim-mode` tests: an interactive stub + that answers just enough of the monitor line protocol for + `RenodeMonitor.drain_boot`/`command` and a real control-socket round + trip to work, so `renode_cmd.py`'s sim IO (bind/spawn/monitor/serve/ + teardown, and the post-spawn `renode.sim-exited-early` / + `renode.cpu-halted` outcomes) is reachable without a real Renode + install. + + Understands: `echo "TOKEN"` (prints `TOKEN` -- the sentinel protocol + every `RenodeMonitor.command` relies on), `quit` (exits 0), `sysbus + WriteByte ` / `sysbus ReadBytes ` (a tiny + byte-addressed memory, mirroring `_FakeMonitor` in + `tests/core/test_renode_sim.py`), and silently ignores anything else + (so `version`, the initial `-e "i @..."` boot argv, etc. never wedge + the loop). + + `preamble` lines are printed UNPROMPTED before the command loop starts + -- used to inject an async `CPU was halted` line the way real Renode's + own boot chatter would. `exit_after_s` starts a BACKGROUND timer that + exits `exit_code` that many seconds after startup regardless of the + (still-running, still-answering) command loop -- used to reproduce + `renode.sim-exited-early` on a session whose `drain_boot` already + succeeded, distinct from `renode.sim-monitor-failed` (which fires when + the child is gone before `drain_boot` ever gets a reply). + """ + bin_dir.mkdir(parents=True, exist_ok=True) + impl = bin_dir / "_fake_sim_renode_impl.py" + preamble_src = "\n".join(f"print({line!r}); sys.stdout.flush()" for line in (preamble or [])) + timer_src = ( + f"threading.Thread(target=lambda: (time.sleep({exit_after_s}), os._exit({exit_code})), " + "daemon=True).start()\n" + if exit_after_s is not None + else "" + ) + body = f'''\ +import os, sys, time, threading + +{preamble_src} +{timer_src} +mem = {{}} +while True: + raw = sys.stdin.readline() + if not raw: + break + s = raw.rstrip("\\r\\n").strip() + if s.startswith('echo "') and s.endswith('"'): + print(s[6:-1]); sys.stdout.flush(); continue + if s == "quit": + sys.exit(0) + parts = s.split() + if len(parts) == 4 and parts[0] == "sysbus" and parts[1] == "WriteByte": + mem[int(parts[2], 0)] = int(parts[3], 0) & 0xFF + continue + if len(parts) == 4 and parts[0] == "sysbus" and parts[1] == "ReadBytes": + addr, count = int(parts[2], 0), int(parts[3], 0) + body = ", ".join(f"0x{{mem.get(addr + i, 0):02X}}" for i in range(count)) + print(f"[\\n{{body}}, \\n]"); sys.stdout.flush(); continue + continue +''' + impl.write_text(body, encoding="utf-8") + _write_renode_wrapper(bin_dir, impl) + + def _write_unspawnable_binary(bin_dir: Path) -> None: """A `renode` that resolves on PATH (passes `on_path`'s existence + X_OK gate) but cannot actually be spawned -- an empty file. Reproduces @@ -304,16 +383,18 @@ def test_multiple_zephyr_slices_without_core_is_a_coded_refusal(tmp_path: Path): assert "--core" in envelope["issues"][0]["message"] -def test_sim_mode_is_a_click_usage_error_not_a_silent_no_op(tmp_path: Path): - """`--sim-mode` is deliberately NOT ported here (see the module docstring - in `renode_cmd.py`): the flag is simply not declared, so Click refuses it - outright rather than accepting it and doing nothing, or doing something - half-implemented.""" +def test_sim_mode_without_image_bundle_is_a_coded_refusal(tmp_path: Path): + """`--sim-mode` IS ported (tan-cli#77): it requires `--image-bundle`, and + refuses with a coded issue -- never a Click usage error, never a silent + no-op -- when it is missing.""" _scaffold(tmp_path, with_elf=True, with_descriptors=True) - exit_code, stdout, stderr = run_renode_cmd(tmp_path, "--sim-mode", path_override="") - assert exit_code == 2 - assert stdout == "" - assert "--sim-mode" in stderr + exit_code, stdout, _stderr = run_renode_cmd( + tmp_path, "--sim-mode", "--format", "json", path_override="" + ) + envelope = json.loads(stdout) + assert exit_code == 1 + assert envelope["issues"][0]["code"] == "renode.sim-bundle-required" + assert "--image-bundle" in envelope["issues"][0]["message"] def test_one_json_document_on_stdout_nothing_else(tmp_path: Path): @@ -568,3 +649,353 @@ def test_run_failed_still_reports_the_argv_that_could_not_be_started(tmp_path: P assert exit_code == 1 assert envelope["issues"][0]["code"] == "renode.run-failed" assert len(envelope["data"]["renodeArgv"]) == 10 + + +# ── --sim-mode (tan-cli#77): the studio hardware-simulator gateway ────────── +# +# Every envelope shape and stream-separation assertion below was diff-verified +# by driving the shipped `tan.exe` oracle live through the full `--sim-mode` +# pipeline (see `renode_cmd.py`'s module docstring) -- not inferred from +# `sim.rs`/`monitor.rs` alone. + + +def _scaffold_sim_bundle( + work: Path, *, manifest: str | None = None, with_elf: bool = True +) -> Path: + """An SDK checkout (loader script + the V2N101 Renode descriptor) plus an + `--image-bundle` directory under `work`. Returns the bundle dir.""" + (work / "sdk" / "scripts").mkdir(parents=True, exist_ok=True) + (work / "sdk" / "scripts" / "alp_project.py").write_text("", encoding="utf-8") + renode_dir = work / "sdk" / "metadata" / "renode" + renode_dir.mkdir(parents=True, exist_ok=True) + (renode_dir / "renesas_rzv2n.repl").write_bytes(b"") + (renode_dir / "renesas_rzv2n.resc").write_bytes(b"") + bundle = work / "bundle" + bundle.mkdir(exist_ok=True) + if manifest is not None: + (bundle / "system-manifest.yaml").write_text(manifest, encoding="utf-8", newline="") + if with_elf: + (bundle / "app.elf").write_bytes(b"") + return bundle + + +def test_sim_bundle_missing_dir_is_a_coded_refusal(tmp_path: Path): + _scaffold_sim_bundle(tmp_path) + exit_code, stdout, _stderr = run_renode_cmd( + tmp_path, + "--sim-mode", + "--image-bundle", + "nope", + "--format", + "json", + path_override="", + ) + envelope = json.loads(stdout) + assert exit_code == 1 + assert envelope["issues"][0]["code"] == "renode.sim-bundle-missing" + + +def test_sim_mode_sku_unresolved_without_board_or_manifest(tmp_path: Path): + _scaffold_sim_bundle(tmp_path) + exit_code, stdout, _stderr = run_renode_cmd( + tmp_path, + "--sim-mode", + "--image-bundle", + "bundle", + "--format", + "json", + path_override="", + ) + envelope = json.loads(stdout) + assert exit_code == 1 + assert envelope["issues"][0]["code"] == "renode.sku-unresolved" + assert "--board" in envelope["issues"][0]["message"] + + +def test_sim_mode_elf_missing_names_what_was_looked_for(tmp_path: Path): + _scaffold_sim_bundle(tmp_path, with_elf=False) + exit_code, stdout, _stderr = run_renode_cmd( + tmp_path, + "--sim-mode", + "--image-bundle", + "bundle", + "--board", + "E1M-V2N101", + "--format", + "json", + path_override="", + ) + envelope = json.loads(stdout) + assert exit_code == 1 + assert envelope["issues"][0]["code"] == "renode.elf-missing" + assert "zephyr.elf" in envelope["issues"][0]["message"] + + +def test_sim_mode_binary_missing_reports_the_plain_mode_log_path_default(tmp_path: Path): + """The oracle-verified divergence: a pre-flight sim failure up to and + including `renode.binary-missing` reports `data.logPath` as the PLAIN + smoke's OWN default (`/build/renode.log`), because the + sim-specific default is only resolved much later -- see the module + docstring in `renode_cmd.py`.""" + _scaffold_sim_bundle(tmp_path) + exit_code, stdout, _stderr = run_renode_cmd( + tmp_path, + "--sim-mode", + "--image-bundle", + "bundle", + "--board", + "E1M-V2N101", + "--format", + "json", + path_override="", + ) + envelope = json.loads(stdout) + assert exit_code == 1 + assert envelope["issues"][0]["code"] == "renode.binary-missing" + log_path = envelope["data"]["logPath"].replace("\\", "/") + assert log_path.endswith("build/renode.log"), log_path + # Resolved BEFORE the binary gate: sku/platformStem/repl/elf all report. + assert envelope["data"]["sku"] == "E1M-V2N101" + assert envelope["data"]["platformStem"] == "renesas_rzv2n" + assert envelope["data"]["elf"] != "" + # Not yet resolved (post-binary-gate): the sim-only fields stay empty/0. + assert envelope["data"]["descriptor"] == "" + assert envelope["data"]["controlPort"] == 0 + assert envelope["data"]["uartPort"] == 0 + + +def test_sim_mode_descriptor_missing_when_the_repl_is_absent(tmp_path: Path): + (tmp_path / "sdk" / "scripts").mkdir(parents=True) + (tmp_path / "sdk" / "scripts" / "alp_project.py").write_text("", encoding="utf-8") + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "app.elf").write_bytes(b"") + exit_code, stdout, _stderr = run_renode_cmd( + tmp_path, + "--sim-mode", + "--image-bundle", + "bundle", + "--board", + "E1M-V2N101", + "--format", + "json", + path_override="", + ) + envelope = json.loads(stdout) + assert exit_code == 1 + assert envelope["issues"][0]["code"] == "renode.descriptor-missing" + + +def test_sim_mode_success_writes_descriptor_and_serves_control_socket(tmp_path: Path): + """The full happy path: pre-flight resolves, the descriptor + boot + script land on disk with the right shape, the control socket answers a + real WriteBytes/ReadBytes round trip while the run is live, and the + envelope reports success with only the deferred-profile warning.""" + import socket + + bundle = _scaffold_sim_bundle(tmp_path) + fake_bin = tmp_path / "fakebin" + _write_fake_sim_renode(fake_bin) + + proc = subprocess.Popen( + [ + sys.executable, + "-c", + _HARNESS, + "--sdk-root", + "./sdk", + "--sim-mode", + "--image-bundle", + "bundle", + "--board", + "E1M-V2N101", + "--timeout", + "6", + "--format", + "json", + ], + cwd=tmp_path, + env={ + **os.environ, + "HOME": str(tmp_path), + "USERPROFILE": str(tmp_path), + "PATH": str(fake_bin), + "PYTHONPATH": str(PACKAGE_ROOT), + }, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + errors="replace", + ) + try: + descriptor_path = bundle / "sim-descriptor.json" + deadline = time.monotonic() + 15 + while not descriptor_path.is_file() and time.monotonic() < deadline: + time.sleep(0.1) + descriptor = json.loads(descriptor_path.read_text(encoding="utf-8")) + assert list(descriptor.keys()) == [ + "control_socket", + "uart_socket", + "framebuffers", + "peripherals", + ] + control_port = int(descriptor["control_socket"].rsplit(":", 1)[1]) + + with socket.create_connection(("127.0.0.1", control_port), timeout=5) as sock: + reader = sock.makefile("rb") + + def send(line: str) -> str: + sock.sendall((line + "\n").encode()) + return reader.readline().decode().rstrip("\r\n") + + assert send("sysbus WriteBytes 0x08010000 0xde 0xad 0xbe 0xef") == "ok" + assert send("sysbus ReadBytes 0x08010000 4") == "0xde 0xad 0xbe 0xef" + + # The UART socket is deferred-SILENT but must stay CONNECTED: studio's + # serial view has to open and simply stay empty, never fail to connect + # and never see an EOF. Mirrors the oracle's own + # `uart_socket_accepts_and_holds_the_connection_open_while_silent` + # (crates/tan-cli/src/commands/renode/sim.rs), which had no Python + # counterpart -- `_serve_uart_silent` could drop every connection with + # the whole suite still green. + uart_port = int(descriptor["uart_socket"].rsplit(":", 1)[1]) + with socket.create_connection(("127.0.0.1", uart_port), timeout=5) as uart: + uart.settimeout(0.25) + uart_deadline = time.monotonic() + 2 + while time.monotonic() < uart_deadline: + try: + chunk = uart.recv(16) + except TimeoutError: + continue # connected-and-silent: the only correct outcome + assert chunk != b"", ( + "the UART socket closed the connection instead of holding it open" + ) + raise AssertionError( + f"the UART socket streamed {len(chunk)} bytes; the streamer " + "is deferred (tan-cli#77)" + ) + + resc_text = (bundle / ".sim-boot.resc").read_text(encoding="utf-8") + assert 'mach create "v2n_sim"' in resc_text + assert "sysbus LoadELF" in resc_text + + stdout, stderr = proc.communicate(timeout=20) + finally: + if proc.poll() is None: + proc.kill() + proc.communicate(timeout=10) + + assert proc.returncode == 0, (stdout, stderr) + assert "tan renode --sim-mode: ready (timeout 6s)." in stderr + envelope = json.loads(stdout) + assert envelope["ok"] is True + assert envelope["exitCode"] == 0 + assert envelope["data"]["sku"] == "E1M-V2N101" + assert envelope["data"]["descriptor"] == str(descriptor_path) + assert envelope["data"]["controlPort"] == control_port + assert envelope["data"]["uartPort"] != 0 + assert [i["code"] for i in envelope["issues"]] == ["renode.sim-profile-deferred"] + + +def test_sim_mode_text_mode_prints_the_header_immediately_to_stdout(tmp_path: Path): + """The header lines (sku/elf, descriptor, control, uart, the deferred + warning) and the readiness marker print DIRECTLY to stdout in text + mode, not buffered until the run ends -- verified stream-separated + against the oracle.""" + _scaffold_sim_bundle(tmp_path) + fake_bin = tmp_path / "fakebin" + _write_fake_sim_renode(fake_bin) + exit_code, stdout, stderr = run_renode_cmd( + tmp_path, + "--sim-mode", + "--image-bundle", + "bundle", + "--board", + "E1M-V2N101", + "--timeout", + "1", + path_override=str(fake_bin), + ) + assert exit_code == 0, stderr + assert "tan renode --sim-mode: E1M-V2N101 booting app.elf" in stdout + assert "descriptor :" in stdout + assert "control :" in stdout + assert "uart :" in stdout + assert "tan-cli#77" in stdout # the deferred-profile warning, printed too + assert "ready (timeout 1s)" in stdout + assert stderr == "" + + +def test_sim_mode_cpu_halted_is_latched_even_though_the_session_comes_up(tmp_path: Path): + _scaffold_sim_bundle(tmp_path) + fake_bin = tmp_path / "fakebin" + _write_fake_sim_renode( + fake_bin, + preamble=["cpu: PC does not lay in memory or PC and SP are equal to zero. CPU was halted."], + ) + exit_code, stdout, _stderr = run_renode_cmd( + tmp_path, + "--sim-mode", + "--image-bundle", + "bundle", + "--board", + "E1M-V2N101", + "--timeout", + "1", + "--format", + "json", + path_override=str(fake_bin), + ) + envelope = json.loads(stdout) + assert exit_code == 1 + codes = [i["code"] for i in envelope["issues"]] + assert "renode.cpu-halted" in codes + + +def test_sim_mode_exited_early_after_drain_boot_succeeded(tmp_path: Path): + _scaffold_sim_bundle(tmp_path) + fake_bin = tmp_path / "fakebin" + _write_fake_sim_renode(fake_bin, exit_after_s=1.0, exit_code=9) + exit_code, stdout, _stderr = run_renode_cmd( + tmp_path, + "--sim-mode", + "--image-bundle", + "bundle", + "--board", + "E1M-V2N101", + "--timeout", + "5", + "--format", + "json", + path_override=str(fake_bin), + ) + envelope = json.loads(stdout) + assert exit_code == 1 + assert envelope["issues"][0]["code"] == "renode.sim-exited-early" + assert "exit code 9" in envelope["issues"][0]["message"] + + +def test_sim_mode_expect_is_ignored_with_an_info_issue(tmp_path: Path): + _scaffold_sim_bundle(tmp_path) + fake_bin = tmp_path / "fakebin" + _write_fake_sim_renode(fake_bin) + exit_code, stdout, _stderr = run_renode_cmd( + tmp_path, + "--sim-mode", + "--image-bundle", + "bundle", + "--board", + "E1M-V2N101", + "--timeout", + "1", + "--expect", + "NEVER-SCANNED", + "--format", + "json", + path_override=str(fake_bin), + ) + envelope = json.loads(stdout) + assert exit_code == 0, envelope + codes = [i["code"] for i in envelope["issues"]] + assert "renode.expect-ignored" in codes + assert "renode.sim-profile-deferred" in codes diff --git a/python/tests/commands/test_scaffold_command.py b/python/tests/commands/test_scaffold_command.py new file mode 100644 index 00000000..07500795 --- /dev/null +++ b/python/tests/commands/test_scaffold_command.py @@ -0,0 +1,377 @@ +# SPDX-License-Identifier: Apache-2.0 +"""``tan scaffold`` -- adds one module into an EXISTING project (#260). + +Driven as a real subprocess, like ``test_init_command.py``/ +``test_build_command.py``: the things worth asserting are ONE JSON document on +stdout, the exit code, and that no input can replace the envelope with a +Python traceback. Every shape asserted here was measured against the frozen +Rust oracle (``target/debug/tan.exe --format json scaffold ...``) rather than +inferred from ``crates/tan-cli/src/commands/scaffold.rs`` alone -- see the +inline comments naming what was actually run. + +``pytest`` gives this subprocess no controlling terminal, so ``--name``/ +``--template`` are effectively ALWAYS required here regardless of whether +``--non-interactive`` is passed explicitly -- the same "no CI runner has a +TTY" fact ``tan.commands.scaffold_cmd``'s own module docstring documents for +the oracle. That is exactly the behaviour under test, not a limitation of it: +the whole point of ``--name``'s non-interactive contract is that it never +silently prompts a caller that cannot answer. +""" +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from tan.core.module_template import MODULE_TEMPLATE_IDS + +#: ``python/`` -- pinned onto the child's PYTHONPATH so ``python -m tan`` +#: resolves from a scratch cwd without a ``pip install``. +PACKAGE_ROOT = Path(__file__).resolve().parents[2] + +WINDOWS = os.name == "nt" + + +def run_tan(*argv, cwd, env_extra=None): + env = { + **os.environ, + "PYTHONPATH": os.pathsep.join( + [str(PACKAGE_ROOT), *([p] if (p := os.environ.get("PYTHONPATH")) else [])] + ), + **(env_extra or {}), + } + return subprocess.run( + [sys.executable, "-m", "tan", *argv], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=str(cwd), + env=env, + ) + + +def envelope(proc): + """The one JSON document on stdout. Fails loudly on zero or two -- both + are the same break for a consumer that parses stdout whole.""" + assert proc.stdout.strip(), f"no envelope on stdout; stderr:\n{proc.stderr}" + assert "Traceback" not in proc.stderr, f"an exception escaped the contract:\n{proc.stderr}" + return json.loads(proc.stdout) + + +def issue(env): + assert env["issues"], "expected at least one issue" + return env["issues"][0] + + +def tree(root: Path): + return sorted(p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()) + + +def _make_dir_link(link: Path, target: Path) -> bool: + """A directory link `link` -> `target`: a Windows JUNCTION (no elevated + privilege needed) or a POSIX symlink. `False` when the host refuses to + make one at all. Same tradeoff `test_init_command.py`/`test_scaffold.py` + already make for the identical reason.""" + target.mkdir(parents=True, exist_ok=True) + if WINDOWS: + made = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(target)], + capture_output=True, + ) + return made.returncode == 0 + link.symlink_to(target, target_is_directory=True) + return True + + +# --------------------------------------------------------------------------- +# --name is required, non-interactively +# --------------------------------------------------------------------------- + + +def test_missing_name_fails_validation_json(tmp_path): + """Measured: `tan --format json scaffold` (no --name, this subprocess has + no TTY) -> exit 2, `scaffold.name-required`. NOT a default -- unlike + `tan init`'s `--name`, a module scaffold has no sane one.""" + proc = run_tan("scaffold", "--format", "json", cwd=tmp_path) + env = envelope(proc) + + assert proc.returncode == 2 + assert env["ok"] is False + assert issue(env)["code"] == "scaffold.name-required" + assert env["project"]["root"] is None + assert env["data"]["templateId"] == "" + assert list(tmp_path.iterdir()) == [], "a validation failure must not touch disk" + + +def test_missing_name_fails_validation_text_mode(tmp_path): + """Text mode: nothing on stdout (the envelope channel stays JSON-only), + the human line on stderr, exit 2 -- matches the oracle's `tan scaffold` + with no TTY attached (measured: stdout empty, stderr carries the line).""" + proc = run_tan("scaffold", cwd=tmp_path) + + assert proc.returncode == 2 + assert proc.stdout == "" + assert "Module name is required" in proc.stderr + + +def test_non_interactive_flag_reports_the_same_refusal(tmp_path): + proc = run_tan("scaffold", "--non-interactive", "--format", "json", cwd=tmp_path) + env = envelope(proc) + assert proc.returncode == 2 + assert issue(env)["code"] == "scaffold.name-required" + + +# --------------------------------------------------------------------------- +# --preview +# --------------------------------------------------------------------------- + + +def test_preview_writes_nothing_at_all(tmp_path): + proc = run_tan( + "scaffold", "--name", "my-sensor", "--preview", "--format", "json", cwd=tmp_path + ) + env = envelope(proc) + + assert proc.returncode == 0 + assert env["data"]["preview"] is True + assert env["data"]["templateId"] == "sensor-driver" # non-interactive default + assert env["data"]["normalizedModuleName"] == "my_sensor" + assert [c["kind"] for c in env["data"]["fileChanges"]] == ["new", "new", "new"] + assert env["data"]["written"] == [] + assert list(tmp_path.iterdir()) == [], "--preview must not touch disk" + + +def test_preview_of_a_project_with_local_edits_still_answers(tmp_path): + """The overwrite guard must stay BEHIND the preview branch -- a read-only + question has nothing to guard (the same ordering bug `tan init` fixed; + ``scaffold.rs`` checks `--preview` before the guard for the same reason).""" + assert ( + run_tan( + "scaffold", "--name", "foo", "--template", "sensor-driver", + "--format", "json", cwd=tmp_path, + ).returncode + == 0 + ) + header = tmp_path / "include" / "modules" / "foo.h" + header.write_text(header.read_text(encoding="utf-8") + "// local edit\n", encoding="utf-8") + + proc = run_tan( + "scaffold", "--name", "foo", "--template", "sensor-driver", + "--preview", "--format", "json", cwd=tmp_path, + ) + env = envelope(proc) + + assert proc.returncode == 0, "a preview must never fail on disk state" + kinds = {c["relativePath"]: c["kind"] for c in env["data"]["fileChanges"]} + assert kinds["include/modules/foo.h"] == "update" + assert "// local edit" in header.read_text(encoding="utf-8"), "preview overwrote a local edit" + + +# --------------------------------------------------------------------------- +# Write / rerun / overwrite guard / --force +# --------------------------------------------------------------------------- + + +def test_write_creates_the_three_files(tmp_path): + proc = run_tan( + "scaffold", "--name", "my-conn", "--template", "connectivity-service", + "--format", "json", cwd=tmp_path, + ) + env = envelope(proc) + + assert proc.returncode == 0 + assert env["ok"] is True + assert sorted(env["data"]["written"]) == [ + "include/modules/my_conn.h", + "src/modules/my_conn/README.md", + "src/modules/my_conn/my_conn.c", + ] + assert env["data"]["unchanged"] == [] + assert tree(tmp_path) == [ + "include/modules/my_conn.h", + "src/modules/my_conn/README.md", + "src/modules/my_conn/my_conn.c", + ] + + +def test_rerun_with_no_changes_reports_unchanged_not_written(tmp_path): + args = ("scaffold", "--name", "my-conn", "--template", "connectivity-service", "--format", "json") + first = run_tan(*args, cwd=tmp_path) + assert first.returncode == 0 + + second = run_tan(*args, cwd=tmp_path) + env = envelope(second) + + assert second.returncode == 0 + assert env["data"]["written"] == [] + assert sorted(env["data"]["unchanged"]) == [ + "include/modules/my_conn.h", + "src/modules/my_conn/README.md", + "src/modules/my_conn/my_conn.c", + ] + + +def test_overwrite_guard_refuses_without_force(tmp_path): + args = ("scaffold", "--name", "foo", "--format", "json") + assert run_tan(*args, cwd=tmp_path).returncode == 0 + edited = tmp_path / "src" / "modules" / "foo" / "foo.c" + edited.write_text(edited.read_text(encoding="utf-8") + "// hand edit\n", encoding="utf-8") + + proc = run_tan(*args, cwd=tmp_path) + env = envelope(proc) + + assert proc.returncode == 3 # ExitCode.WRITE_FAILURE + assert issue(env)["code"] == "scaffold.would-overwrite" + assert env["data"]["written"] == [] + assert "// hand edit" in edited.read_text(encoding="utf-8"), "refused write must not touch disk" + + +def test_force_allows_the_overwrite(tmp_path): + args = ["scaffold", "--name", "foo", "--format", "json"] + assert run_tan(*args, cwd=tmp_path).returncode == 0 + edited = tmp_path / "src" / "modules" / "foo" / "foo.c" + edited.write_text(edited.read_text(encoding="utf-8") + "// hand edit\n", encoding="utf-8") + + proc = run_tan(*args, "--force", cwd=tmp_path) + env = envelope(proc) + + assert proc.returncode == 0 + assert env["data"]["written"] == ["src/modules/foo/foo.c"] + assert "// hand edit" not in edited.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +def test_invalid_template_reports_coded_issue(tmp_path): + proc = run_tan( + "scaffold", "--name", "foo", "--template", "bogus", "--format", "json", cwd=tmp_path + ) + env = envelope(proc) + + assert proc.returncode == 2 + assert issue(env)["code"] == "scaffold.invalid-template" + assert "bogus" in issue(env)["message"] + assert env["project"]["root"] is None + + +def test_name_that_normalizes_to_empty_reports_coded_issue(tmp_path): + proc = run_tan("scaffold", "--name", "!!!", "--format", "json", cwd=tmp_path) + env = envelope(proc) + + assert proc.returncode == 2 + assert issue(env)["code"] == "scaffold.invalid-name" + + +@pytest.mark.parametrize("template_id", MODULE_TEMPLATE_IDS) +def test_every_registered_template_plans_three_files(template_id, tmp_path): + proc = run_tan( + "scaffold", "--name", "mod", "--template", template_id, + "--preview", "--format", "json", cwd=tmp_path, + ) + env = envelope(proc) + + assert proc.returncode == 0, env["issues"] + assert env["data"]["templateId"] == template_id + assert len(env["data"]["fileChanges"]) == 3 + + +# --------------------------------------------------------------------------- +# --destination / --project +# --------------------------------------------------------------------------- + + +def test_destination_flag_wins_over_project(tmp_path): + (tmp_path / "sub").mkdir() + proc = run_tan( + "scaffold", "--name", "bar", "--destination", "sub", "--project", "elsewhere", + "--format", "json", cwd=tmp_path, + ) + env = envelope(proc) + + assert proc.returncode == 0 + assert env["project"]["root"] == "sub" + assert (tmp_path / "sub" / "include" / "modules" / "bar.h").is_file() + + +def test_project_flag_used_when_no_destination(tmp_path): + (tmp_path / "sub").mkdir() + proc = run_tan( + "scaffold", "--name", "baz", "--project", "sub", "--format", "json", cwd=tmp_path + ) + env = envelope(proc) + + assert proc.returncode == 0 + assert env["project"]["root"] == "sub" + assert (tmp_path / "sub" / "include" / "modules" / "baz.h").is_file() + + +# --------------------------------------------------------------------------- +# The oracle's global flag set: accepted even though scaffold reads almost +# none of them (`--project` is the one exception). +# --------------------------------------------------------------------------- + + +def test_every_global_flag_is_accepted_and_ignored(tmp_path): + proc = run_tan( + "scaffold", "--name", "qux", "--preview", + "--board-yaml", str(tmp_path / "nonexistent.yaml"), + "--sdk-root", str(tmp_path / "nonexistent-sdk"), + "--target", "zephyr-conf", "--all", "--verbose", "--quiet", "--no-color", + "--non-interactive", "--ci", + "--format", "json", + cwd=tmp_path, + ) + env = envelope(proc) + assert proc.returncode == 0 + assert env["data"]["moduleName"] == "qux" + + +# --------------------------------------------------------------------------- +# Envelope shape +# --------------------------------------------------------------------------- + + +def test_envelope_has_every_contract_key(tmp_path): + proc = run_tan( + "scaffold", "--name", "shapecheck", "--preview", "--format", "json", cwd=tmp_path + ) + env = envelope(proc) + assert set(env.keys()) == {"command", "ok", "exitCode", "project", "data", "issues"} + assert env["command"] == "scaffold" + assert env["ok"] == (proc.returncode == 0) + assert env["exitCode"] == proc.returncode + assert "sdk" not in env # scaffold never resolves an SDK (I-32) + + +# --------------------------------------------------------------------------- +# tan-cli#325: writes are confined to the project root +# --------------------------------------------------------------------------- + + +def test_write_refuses_through_a_symlinked_parent_directory(tmp_path): + """`/include` is a pre-existing directory link to somewhere + outside the project. `tan.core.scaffold.write_files` (reused here, not + reimplemented) must refuse the whole run rather than following the link + and reporting the in-project logical path as written.""" + project = tmp_path / "project" + outside = tmp_path / "outside" + project.mkdir() + if not _make_dir_link(project / "include", outside): + pytest.skip("cannot create a directory link on this host") + + proc = run_tan( + "scaffold", "--name", "esc", "--destination", str(project), "--format", "json", + cwd=tmp_path, + ) + env = envelope(proc) + + assert proc.returncode == 3 # ExitCode.WRITE_FAILURE + assert env["ok"] is False + assert issue(env)["code"] == "scaffold.write-failed" + assert not any(outside.rglob("*")), "nothing may land outside the project through the link" diff --git a/python/tests/commands/test_sdk_command.py b/python/tests/commands/test_sdk_command.py index ae657b0f..f9795958 100644 --- a/python/tests/commands/test_sdk_command.py +++ b/python/tests/commands/test_sdk_command.py @@ -400,16 +400,37 @@ def test_current_names_an_unresolvable_project_pin_instead_of_reporting_it_silen ] -def test_list_refuses_without_online_and_touches_no_network(tmp_path, isolated_home): +def test_list_without_online_answers_offline_and_touches_no_network(tmp_path, isolated_home): + """tan-cli#351: bare `sdk list` is a NORMAL state, matching `sdk current`'s + "nothing configured" -- exit 0, `ok: true` -- not a failure. The oracle has + no `--online` flag and always reaches the network for `sdk list`; gating it + is this port's own hermeticity addition (I-23), so the gate itself must not + read as an error. `sdk.network-required` survives as a `warning`-severity + issue (still present, still the code a consumer can key on), not an + `error` on a passing envelope.""" proc = run_tan("sdk", "list", "--format", "json", cwd=tmp_path) - assert proc.returncode == 1 + assert proc.returncode == 0 env = envelope(proc) + assert env["ok"] is True assert env["issues"][0]["code"] == "sdk.network-required" - # The `list`-shaped payload survives the refusal: the extension reads - # `data.releases` with a `?? []` fallback. + assert env["issues"][0]["severity"] == "warning" + assert "upstream" in env["issues"][0]["message"].lower() + assert "--online" in env["issues"][0]["message"] + # The `list`-shaped payload survives: the extension reads `data.releases` + # with a `?? []` fallback. assert env["data"] == {"subcommand": "list", "releases": []} +def test_list_without_online_text_mode_names_upstream_and_the_flag(tmp_path, isolated_home): + """Text mode gets the same "this is normal, here's the switch" framing as + JSON, not the old "this command needs network access" wording that read + like a broken host rather than a plain missing flag.""" + proc = run_tan("sdk", "list", cwd=tmp_path) + assert proc.returncode == 0 + assert "upstream" in proc.stderr.lower() + assert "--online" in proc.stderr + + @pytest.mark.parametrize("verb", ["install", "switch"]) def test_install_and_switch_refuse_loudly_rather_than_half_working(tmp_path, isolated_home, verb): """A partial `switch` writes the active-SDK pointer but skips the diff --git a/python/tests/commands/test_sdk_onboarding_dead_end.py b/python/tests/commands/test_sdk_onboarding_dead_end.py index 4379052b..ba88267d 100644 --- a/python/tests/commands/test_sdk_onboarding_dead_end.py +++ b/python/tests/commands/test_sdk_onboarding_dead_end.py @@ -166,7 +166,7 @@ def test_new_som_sdk_root_unresolved_never_recommends_a_refused_subcommand(tmp_p "--family", "fam", ], ) - assert result.exit_code == 1 + assert result.exit_code == 2 assert "alp-sdk root is unresolved" in result.output assert_no_refused_subcommand_named(result.output) diff --git a/python/tests/commands/test_support_bundle_command.py b/python/tests/commands/test_support_bundle_command.py new file mode 100644 index 00000000..212f4fb1 --- /dev/null +++ b/python/tests/commands/test_support_bundle_command.py @@ -0,0 +1,664 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan support-bundle` -- port of `crates/tan-cli/src/commands/support_bundle.rs`. + +Envelope/exit-code shapes below were measured against a freshly-built oracle +(`cargo build -p alp-tan-cli --bin tan` from THIS worktree's `crates/`). + +**The bundled doctor section is the oracle's DEBUG-focused report** +(tan-cli#357): `workspaceRoot`/`sdkRoot`/`boardYaml`, the per-target-kind +extension + tool pair, then the host checks. Before #357 this module +substituted `doctor_cmd._collect`'s whole build/flash-readiness checklist and +declared that a deliberate divergence; a bundle attached to a debug failure +therefore carried no debugger state at all. The four host checks are still +`doctor_cmd`'s own -- harvested by name from `_collect` -- so they are +monkeypatched here with a deterministic list, keeping these tests independent +of this host's Zephyr/tool state. + +**Exit follows the oracle's rule: `summary.fail > 0` -> `DOCTOR_FAILURE` (4), +`ok: false`.** A warn does NOT flip it, and the bundle file is written on the +failing path either way. The live cross-check against the real oracle lives in +`tests/parity/test_support_bundle_oracle_parity.py`; these cases pin the same +rule without needing a built Rust binary. + +`support-bundle` IS registered in `tan.cli.app` (`tan/cli.py:34,105` -- and +`tests/parity/test_support_bundle_oracle_parity.py` depends on that +registration to spawn it). tan-cli#374 finding 8: an earlier version of this +note claimed otherwise. These tests still build a throwaway local Typer app +around the ported command function directly, the same lighter-weight harness +`test_trace_command.py`/`test_inspect_command.py` use for their own +already-registered commands -- not a workaround for missing registration. +""" +from __future__ import annotations + +import json +import os + +import typer +from typer.testing import CliRunner + +from tan.commands import doctor_cmd +from tan.commands.support_bundle_cmd import _home_variants, _redact, support_bundle + + +def _local_app(): + local = typer.Typer() + + @local.callback(invoke_without_command=True) + def root(ctx: typer.Context, output_format: str = typer.Option(None, "--format")) -> None: + ctx.obj = {"format": output_format} + + local.command("support-bundle")(support_bundle) + return local + + +app = _local_app() +runner = CliRunner() + + +def write(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8", newline="") + + +def sdk_at(root): + write(root / "scripts" / "alp_project.py", "# stub") + + +def _clean_checks(*, fail=False, warn=False): + """A deterministic `doctor_cmd._collect` return, standing in for whatever + this real host's tools/Zephyr workspace happen to report -- keeps the + end-to-end tests below independent of CI/dev-machine state. + + The `sdk` entry is deliberately one the bundle must DROP: only the five + names in `_HOST_CHECK_ORDER` are harvested, and `sdk` is the build + checklist's own SDK verdict, superseded here by the debug report's + `sdkRoot`.""" + status = "fail" if fail else ("warn" if warn else "pass") + checks = [doctor_cmd.Check("sdk", "pass", "alp-sdk at /sdk")] + if fail or warn: + checks.append( + doctor_cmd.Check( + "hostPrerequisites", + status, + "missing from PATH: ninja." if fail else "west is old.", + fix="Install the missing prerequisites, then run `tan bootstrap`.", + ) + ) + else: + checks.append(doctor_cmd.Check("hostPrerequisites", "pass", "git, cmake present")) + return checks + + +# --------------------------------------------------------------------------- +# Redaction -- the critical property this command has to get right. +# --------------------------------------------------------------------------- + + +def test_redact_replaces_every_occurrence_recursively(): + payload = { + "a": "prefix C:\\Users\\alice\\proj suffix", + "b": ["C:\\Users\\alice\\one", "unrelated"], + "c": {"d": "C:/Users/alice/posix/path"}, + "e": True, + "f": None, + "g": 3, + } + redacted = _redact(payload, ("C:\\Users\\alice", "C:/Users/alice")) + assert redacted["a"] == "prefix \\proj suffix" + assert redacted["b"] == ["\\one", "unrelated"] + assert redacted["c"]["d"] == "/posix/path" + # Non-strings pass through unchanged, not stringified. + assert redacted["e"] is True + assert redacted["f"] is None + assert redacted["g"] == 3 + + +def test_redact_is_a_noop_with_no_home_variants(): + payload = {"a": "C:\\Users\\alice\\proj"} + assert _redact(payload, ()) == payload + + +def test_home_variants_covers_native_and_posix_spelling(monkeypatch): + env_key = "USERPROFILE" if os.name == "nt" else "HOME" + monkeypatch.setenv(env_key, "C:\\Users\\alice" if os.name == "nt" else "/home/alice") + variants = _home_variants() + assert len(variants) >= 1 + if os.name == "nt": + assert "C:\\Users\\alice" in variants + assert "C:/Users/alice" in variants + + +def test_home_variants_empty_when_unset(monkeypatch): + env_key = "USERPROFILE" if os.name == "nt" else "HOME" + monkeypatch.delenv(env_key, raising=False) + assert _home_variants() == () + + +def test_written_bundle_never_contains_the_raw_home_directory(tmp_path, monkeypatch): + """The end-to-end property: a project living UNDER the resolved home + directory must not leak that home path anywhere in the WRITTEN file -- + only the stdout envelope (never attached wholesale to a public issue the + way the file is) may still carry it.""" + env_key = "USERPROFILE" if os.name == "nt" else "HOME" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv(env_key, str(home)) + project = home / "proj" + write(project / "board.yaml", "x") + monkeypatch.chdir(project) + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: _clean_checks()) + + result = runner.invoke(app, ["support-bundle", "--format", "json"]) + doc = json.loads(result.stdout) + output_path = doc["data"]["outputPath"] + # The stdout envelope is NOT redacted -- it must stay a real, followable + # path for whatever just asked for it. + assert str(home) in output_path or str(home).replace("\\", "/") in output_path + + bundle_text = open(output_path, encoding="utf-8").read() + home_native = str(home) + home_posix = home_native.replace("\\", "/") + assert home_native not in bundle_text + assert home_posix not in bundle_text + assert "" in bundle_text + # The project's own sub-path under home survives, minus the home prefix. + assert "proj" in bundle_text + + +def test_a_workspace_outside_home_is_left_legible_in_the_bundle(tmp_path, monkeypatch): + """Redaction is narrow: a project OUTSIDE the home directory is not + touched at all -- a maintainer reading the file needs the real layout.""" + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "x") + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: _clean_checks()) + + result = runner.invoke(app, ["support-bundle", "--format", "json"]) + doc = json.loads(result.stdout) + bundle_text = open(doc["data"]["outputPath"], encoding="utf-8").read() + posix_root = str(tmp_path).replace("\\", "/") + assert posix_root in bundle_text + + +# --------------------------------------------------------------------------- +# Target/server validation +# --------------------------------------------------------------------------- + + +def test_verbose_hint_never_appears_on_a_failure_path(tmp_path, monkeypatch): + """Measured against the oracle: `--verbose` together with a server- + incompatible refusal prints only the one incompatibility line -- the + "include --format json" hint is exclusive to the bundle-written success + text, not a blanket verbose flag.""" + monkeypatch.chdir(tmp_path) + result = runner.invoke( + app, + [ + "support-bundle", + "--target-kind", + "yocto-userspace", + "--server", + "jlink", + "--verbose", + ], + ) + assert result.exit_code == 4 + assert "include --format json" not in result.stderr + assert "not supported for target" in result.stderr + + +def test_server_incompatible_with_target_is_doctor_failure(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke( + app, + [ + "support-bundle", + "--target-kind", + "yocto-userspace", + "--server", + "jlink", + "--format", + "json", + ], + ) + assert result.exit_code == 4 + doc = json.loads(result.stdout) + assert doc["data"]["outputPath"] == "" + assert doc["issues"] == [ + { + "code": "support-bundle.server-compatibility", + "severity": "error", + "message": "Server 'jlink' is not supported for target 'yocto-userspace'.", + } + ] + # No file written on this path. + assert not (tmp_path / ".alp-support").exists() + + +def test_invalid_target_kind_is_an_internal_failure(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke( + app, ["support-bundle", "--target-kind", "bogus", "--format", "json"] + ) + assert result.exit_code == 5 + doc = json.loads(result.stdout) + assert doc["issues"][0]["code"] == "support-bundle.internal-failure" + assert "bogus" in doc["issues"][0]["message"] + # Measured against the oracle: the raw invalid value is never echoed back + # into data.targetKind/data.server -- both report the defaults. + assert doc["data"]["targetKind"] == "native-host" + assert doc["data"]["server"] == "none" + + +def test_invalid_server_is_an_internal_failure(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["support-bundle", "--server", "bogus", "--format", "json"]) + assert result.exit_code == 5 + doc = json.loads(result.stdout) + assert doc["issues"][0]["code"] == "support-bundle.internal-failure" + # Measured against the oracle: --server bogus alone still reports the + # DEFAULT server ("none"), not the raw invalid "bogus" value. + assert doc["data"]["targetKind"] == "native-host" + assert doc["data"]["server"] == "none" + + +def test_a_valid_target_kind_with_an_invalid_server_still_reports_defaults_for_both( + tmp_path, monkeypatch +): + """Measured against the oracle: `--target-kind zephyr-mcu --server bogus` + -> rc=5 targetKind="native-host" server="none" -- a partial parse failure + resets BOTH fields to their defaults, not just the one that failed.""" + monkeypatch.chdir(tmp_path) + result = runner.invoke( + app, + [ + "support-bundle", + "--target-kind", + "zephyr-mcu", + "--server", + "bogus", + "--format", + "json", + ], + ) + assert result.exit_code == 5 + doc = json.loads(result.stdout) + assert doc["data"]["targetKind"] == "native-host" + assert doc["data"]["server"] == "none" + + +# --------------------------------------------------------------------------- +# Trace section leniency -- checks Option-presence, never file existence +# --------------------------------------------------------------------------- + + +def test_trace_section_still_plans_all_four_targets_without_a_real_board_yaml( + tmp_path, monkeypatch +): + """Measured against the oracle: unlike bare `tan trace`, a resolved SDK + with a MISSING board.yaml still gets four Planned decisions here.""" + monkeypatch.chdir(tmp_path) + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: _clean_checks()) + + result = runner.invoke(app, ["support-bundle", "--sdk-root", str(sdk), "--format", "json"]) + doc = json.loads(result.stdout) + assert doc["data"]["decisionCount"] == 4 + + +def test_trace_section_falls_back_to_one_failed_decision_with_no_sdk(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "x") + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: _clean_checks(fail=True)) + + result = runner.invoke(app, ["support-bundle", "--format", "json"]) + doc = json.loads(result.stdout) + assert doc["data"]["decisionCount"] == 1 + + +def test_path_focus_adds_one_more_decision(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: _clean_checks()) + + result = runner.invoke( + app, ["support-bundle", "--sdk-root", str(sdk), "--path", "som.sku", "--format", "json"] + ) + doc = json.loads(result.stdout) + assert doc["data"]["decisionCount"] == 5 + + +def test_unknown_generation_target_is_an_internal_failure(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + result = runner.invoke( + app, ["support-bundle", "--sdk-root", str(sdk), "--target", "bogus", "--format", "json"] + ) + assert result.exit_code == 5 + doc = json.loads(result.stdout) + assert doc["issues"][0]["code"] == "support-bundle.internal-failure" + assert "bogus" in doc["issues"][0]["message"] + + +# --------------------------------------------------------------------------- +# Doctor -> issues / exit code wiring +# --------------------------------------------------------------------------- + + +def _healthy(tmp_path, monkeypatch, **kwargs): + """A project whose DEBUG report's own three base checks all pass: a + board.yaml on disk and a resolvable SDK. Without the SDK, `sdkRoot` fails + and the exit code is 4 for a reason that has nothing to do with the + harvested host checks under test.""" + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "x") + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: _clean_checks(**kwargs)) + return ["support-bundle", "--sdk-root", str(sdk), "--format", "json"] + + +def test_clean_doctor_checks_mean_success_and_no_issues(tmp_path, monkeypatch): + argv = _healthy(tmp_path, monkeypatch) + result = runner.invoke(app, argv) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["ok"] is True + assert doc["issues"] == [] + + +def test_a_failing_check_is_a_doctor_failure_not_a_silent_success(tmp_path, monkeypatch): + """tan-cli#357, the regression this file exists to hold. The oracle's rule + is `if doctor.summary.fail > 0 { ExitCode::DoctorFailure }`; this port + hardcoded `SUCCESS`, so automation read `ok: true` and exit 0 out of the + same envelope that carried an error-severity issue. Measured against the + oracle on the identical failing host: rc=4, ok=false, exitCode=4. + + The three assertions are one invariant, not three: process exit code == + `envelope.exitCode` == `not ok`. Fixing any one of them alone leaves a + consumer trusting whichever it happens to read.""" + argv = _healthy(tmp_path, monkeypatch, fail=True) + result = runner.invoke(app, argv) + assert result.exit_code == 4 + doc = json.loads(result.stdout) + assert doc["exitCode"] == 4 + assert doc["ok"] is False + issue = next(i for i in doc["issues"] if i["code"] == "support-bundle.hostPrerequisites") + assert issue["severity"] == "error" + assert issue["message"] == "missing from PATH: ninja." + # The bundle file is still written on a doctor failure -- it is precisely + # what the user attaches, and the failure is why they are attaching it. + assert doc["data"]["outputPath"] != "" + assert os.path.isfile(doc["data"]["outputPath"]) + + +def test_a_warning_check_becomes_a_warning_issue_and_stays_exit_zero(tmp_path, monkeypatch): + """`summary.fail > 0`, never `warn > 0`: the oracle counts warns and + ignores them for the exit code, so a bundle from a merely-degraded host + still exports cleanly.""" + argv = _healthy(tmp_path, monkeypatch, warn=True) + result = runner.invoke(app, argv) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["ok"] is True + issue = next(i for i in doc["issues"] if i["code"] == "support-bundle.hostPrerequisites") + assert issue["severity"] == "warning" + + +def test_an_unresolved_sdk_root_fails_the_bundle(tmp_path, monkeypatch): + """The oracle's `sdkRoot` check is `status_pass_fail(has_sdk)` -- a hard + fail, so a bundle taken with no alp-sdk checkout exits 4. Measured against + the oracle from a project with no resolvable SDK: rc=4, issues include + `support-bundle.sdkRoot` at error severity.""" + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "x") + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: _clean_checks()) + + result = runner.invoke(app, ["support-bundle", "--format", "json"]) + assert result.exit_code == 4 + doc = json.loads(result.stdout) + assert doc["ok"] is False + issue = next(i for i in doc["issues"] if i["code"] == "support-bundle.sdkRoot") + assert issue["severity"] == "error" + assert issue["message"] == "No alp-sdk checkout resolved." + + +# --------------------------------------------------------------------------- +# The DEBUG-focused doctor report (tan-cli#357) +# --------------------------------------------------------------------------- + + +def _bundle(result): + return json.loads(open(json.loads(result.stdout)["data"]["outputPath"], encoding="utf-8").read()) + + +def test_the_bundle_carries_the_debug_report_not_the_build_checklist(tmp_path, monkeypatch): + """The other half of #357: the check LIST. `_collect`'s build/flash- + readiness names (`sdk`, `setools`, `jlink`, `west`, ...) are not what a + debug bundle is for, and substituting them dropped `codeLLDBExtension`/ + `lldb` -- the debugger facts this command exists to collect.""" + argv = _healthy(tmp_path, monkeypatch) + names = [c["name"] for c in _bundle(runner.invoke(app, argv))["doctor"]["checks"]] + assert names == [ + "workspaceRoot", + "sdkRoot", + "boardYaml", + "codeLLDBExtension", + "lldb", + "hostPrerequisites", + ] + # `sdk` is in the monkeypatched `_collect` list and must NOT ride along -- + # only the five harvested host names do. + assert "sdk" not in names + + +def test_the_real_collect_produces_the_oracle_shaped_check_list(tmp_path, monkeypatch): + """tan-cli#374 finding 7: every OTHER test in this module monkeypatches + `doctor_cmd._collect` with the 2-check `_clean_checks()` stub, so none of + them would notice a check entering or leaving the bundle -- exactly how + findings 1 (`longPaths`'s undue `fail` arm) and 5 (the undeclared + `bootstrapManifest` divergence) reached production with no unit test + failing. + + This one runs the REAL `_collect`, against a resolvable SDK carrying a + real, readable `metadata/bootstrap.json` (so `bootstrapManifest` -- a + documented, separately-tracked port-only divergence, finding 5 -- does + not fire and inflate the count), and pins the check-NAME list only: the + oracle's own shape (`longPaths` is Windows-only, hence the platform + branch), regardless of what THIS host's real tools/registry answer each + one with -- unlike `_healthy`'s deterministic stub, a real status here + would be host-dependent and not this test's job to pin. + """ + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "x") + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + write( + sdk / "metadata" / "bootstrap.json", + json.dumps( + { + "prerequisites": { + "posix": [], + "windows": [], + "pythonMinVersion": "3.10", + "install": {}, + } + } + ), + ) + + result = runner.invoke(app, ["support-bundle", "--sdk-root", str(sdk), "--format", "json"]) + names = [c["name"] for c in _bundle(result)["doctor"]["checks"]] + expected = [ + "workspaceRoot", + "sdkRoot", + "boardYaml", + "codeLLDBExtension", + "lldb", + "hostPrerequisites", + "zephyrSdkAvailableForHost", + ] + if os.name == "nt": + expected.append("longPaths") + expected.append("homePath") + assert names == expected + assert "bootstrapManifest" not in names + + +def test_the_extension_checks_are_unknown_and_count_toward_nothing(tmp_path, monkeypatch): + """#102: the standalone binary cannot enumerate VS Code's extensions, so + `codeLLDBExtension` must not claim an install state nobody probed, must + not join the pass total, and must raise no issue -- otherwise it could + move an exit code off a question that was never asked.""" + argv = _healthy(tmp_path, monkeypatch) + result = runner.invoke(app, argv) + doctor = _bundle(result)["doctor"] + check = next(c for c in doctor["checks"] if c["name"] == "codeLLDBExtension") + assert check["status"] == "unknown" + assert "is installed" not in check["detail"] + assert "fix" not in check + assert sum(doctor["summary"].values()) < len(doctor["checks"]) + assert not [i for i in json.loads(result.stdout)["issues"] if "Extension" in i["code"]] + + +def test_lldb_passes_even_with_none_on_path(tmp_path, monkeypatch): + """#131: `vadimcn.vscode-lldb` ships its own LLDB and never reads PATH, so + a bare-PATH miss must not warn or offer an install remedy that fixes + nothing.""" + argv = _healthy(tmp_path, monkeypatch) + monkeypatch.setattr(doctor_cmd, "on_path", lambda name: None) + check = next( + c for c in _bundle(runner.invoke(app, argv))["doctor"]["checks"] if c["name"] == "lldb" + ) + assert check["status"] == "pass" + assert "fix" not in check + assert "ships its own LLDB" in check["detail"] + + +def test_a_zephyr_target_swaps_in_the_cortex_and_backend_checks(tmp_path, monkeypatch): + """`--target-kind`/`--server` genuinely change the report now -- before + #357 the bundled checklist never branched on either.""" + argv = _healthy(tmp_path, monkeypatch) + monkeypatch.setattr(doctor_cmd, "on_path", lambda name: None) + result = runner.invoke( + app, [*argv, "--target-kind", "zephyr-mcu", "--server", "jlink"] + ) + checks = {c["name"]: c for c in _bundle(result)["doctor"]["checks"]} + assert "cortexDebugExtension" in checks + assert checks["jlinkBackend"]["status"] == "warn" + assert checks["jlinkBackend"]["detail"] == "No jlink executable was found on PATH." + # A warn, so the bundle still exports cleanly. + assert result.exit_code == 0 + + +def test_a_yocto_target_swaps_in_the_cpptools_and_gdb_checks(tmp_path, monkeypatch): + argv = _healthy(tmp_path, monkeypatch) + monkeypatch.setattr(doctor_cmd, "on_path", lambda name: name if name == "gdb" else None) + # `gdbserver` explicitly: `none` is not a supported server for this target, + # and the pairing guard would refuse before any report is built. + result = runner.invoke( + app, [*argv, "--target-kind", "yocto-userspace", "--server", "gdbserver"] + ) + checks = {c["name"]: c for c in _bundle(result)["doctor"]["checks"]} + assert "cppToolsExtension" in checks + assert checks["gdb"]["status"] == "pass" + assert checks["gdb"]["detail"] == "gdb" + + +def test_the_context_carries_project_selected_and_debugger_extensions(tmp_path, monkeypatch): + """Both were omitted before #357 as "IDE-extension-host concepts with no + standalone reader". `projectSelected` is derived from this invocation's + own flags, and `debuggerExtensions` carries `observable: false`, which + says in the file itself that nothing probed the three flags.""" + argv = _healthy(tmp_path, monkeypatch) + context = _bundle(runner.invoke(app, argv))["inspect"]["context"] + assert context["projectSelected"] is False + assert context["debuggerExtensions"] == { + "cortexDebug": True, + "cppTools": True, + "codeLLDB": True, + "observable": False, + } + + selected = _bundle(runner.invoke(app, [*argv, "--project", str(tmp_path)])) + assert selected["inspect"]["context"]["projectSelected"] is True + + +def test_a_missing_board_yaml_warns_until_a_project_is_selected(tmp_path, monkeypatch): + """#100: `tan bootstrap` sends every new customer to run tan from the SDK + checkout root, which has no board.yaml and needs none -- so a missing one + is a hard failure only once `--project`/`--board-yaml` NAMED a project. + The exit code follows: warn keeps the bundle at 0, fail takes it to 4.""" + monkeypatch.chdir(tmp_path) + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: _clean_checks()) + argv = ["support-bundle", "--sdk-root", str(sdk), "--format", "json"] + + unselected = runner.invoke(app, argv) + check = next( + c for c in _bundle(unselected)["doctor"]["checks"] if c["name"] == "boardYaml" + ) + assert check["status"] == "warn" + assert "no project selected" in check["detail"] + assert unselected.exit_code == 0 + + selected = runner.invoke(app, [*argv, "--project", str(tmp_path)]) + check = next(c for c in _bundle(selected)["doctor"]["checks"] if c["name"] == "boardYaml") + assert check["status"] == "fail" + assert selected.exit_code == 4 + + +# --------------------------------------------------------------------------- +# --destination +# --------------------------------------------------------------------------- + + +def test_explicit_destination_is_used_verbatim(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "x") + dest = tmp_path / "custom-dest" + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: _clean_checks()) + + result = runner.invoke( + app, ["support-bundle", "--destination", str(dest), "--format", "json"] + ) + doc = json.loads(result.stdout) + output_path = doc["data"]["outputPath"] + assert os.path.dirname(output_path) == str(dest) + assert dest.is_dir() + + +def test_default_destination_is_dot_alp_support_under_the_workspace(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "x") + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: _clean_checks()) + + result = runner.invoke(app, ["support-bundle", "--format", "json"]) + doc = json.loads(result.stdout) + assert (tmp_path / ".alp-support").is_dir() + assert os.path.basename(os.path.dirname(doc["data"]["outputPath"])) == ".alp-support" + + +# --------------------------------------------------------------------------- +# Misc +# --------------------------------------------------------------------------- + + +def test_verbose_text_mode_adds_the_json_hint(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "x") + monkeypatch.setattr(doctor_cmd, "_collect", lambda *a, **k: _clean_checks()) + + quiet = runner.invoke(app, ["support-bundle"]) + verbose = runner.invoke(app, ["support-bundle", "--verbose"]) + assert "include --format json" not in quiet.stderr + assert "include --format json" in verbose.stderr + + +def test_a_bad_format_is_a_usage_error_not_a_traceback(): + result = runner.invoke(app, ["support-bundle", "--format", "yaml"]) + assert result.exit_code == 2 + assert "Traceback" not in result.output diff --git a/python/tests/commands/test_trace_command.py b/python/tests/commands/test_trace_command.py new file mode 100644 index 00000000..db4cc1f5 --- /dev/null +++ b/python/tests/commands/test_trace_command.py @@ -0,0 +1,266 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan trace` -- port of `crates/tan-cli/src/commands/trace.rs`. + +Every shape asserted below was measured against a freshly-built oracle +(`cargo build -p alp-tan-cli --bin tan` from THIS worktree's `crates/`) -- +including the mixed-separator `outputPath`/command-line shape, which is a +genuine byte-for-byte requirement, not a stylistic choice (see +`trace_cmd`'s module docstring). + +`trace` is not yet registered in `tan.cli.app` (the orchestrator's to wire, +per `deferred_cmd.py`'s module docstring), so these tests build a throwaway +local Typer app around the ported command function directly. +""" +from __future__ import annotations + +import json +import os + +import pytest +import typer +from typer.testing import CliRunner + +from tan.commands.trace_cmd import ( + BUILD_CONFIG_EMIT_MODES, + TraceTargetError, + _loader_plan, + build_trace_decisions, + resolve_trace_targets, + trace, +) + + +def _local_app(): + local = typer.Typer() + + @local.callback(invoke_without_command=True) + def root(ctx: typer.Context, output_format: str = typer.Option(None, "--format")) -> None: + ctx.obj = {"format": output_format} + + local.command("trace")(trace) + return local + + +app = _local_app() +runner = CliRunner() + + +def write(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8", newline="") + + +def sdk_at(root): + write(root / "scripts" / "alp_project.py", "# stub") + + +# --------------------------------------------------------------------------- +# Target resolution -- deliberately the narrower build-config set +# --------------------------------------------------------------------------- + + +def test_default_targets_are_exactly_the_build_config_set_in_order(): + assert resolve_trace_targets(None) == ( + "zephyr-conf", + "dts-overlay", + "cmake-args", + "yocto-conf", + ) + assert BUILD_CONFIG_EMIT_MODES == resolve_trace_targets(None) + + +def test_a_generate_only_target_is_not_a_valid_trace_target(): + """`carrier-netlist` is a real `tan generate --target`, but a build never + materialises it -- `tan trace` must still refuse it (tan-cli#165 review + finding 1).""" + with pytest.raises(TraceTargetError) as excinfo: + resolve_trace_targets("carrier-netlist") + assert str(excinfo.value) == ( + "Unsupported trace target 'carrier-netlist'. Allowed values: " + "zephyr-conf, dts-overlay, cmake-args, yocto-conf." + ) + + +def test_a_known_target_narrows_to_one(): + assert resolve_trace_targets("cmake-args") == ("cmake-args",) + + +# --------------------------------------------------------------------------- +# _loader_plan -- the exact mixed-separator join shape +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(os.name != "nt", reason="the mixed-separator shape is Windows-specific") +def test_loader_plan_matches_the_oracles_single_join_shape_on_windows(): + output_path, command_line = _loader_plan( + "C:/proj", "C:/sdk", "C:/proj/board.yaml", "python", "zephyr-conf" + ) + assert output_path == "C:/proj\\build/generated/alp.conf" + assert command_line == ( + "python C:/sdk\\scripts\\alp_project.py --input C:/proj/board.yaml " + "--emit zephyr-conf --output C:/proj\\build/generated/alp.conf" + ) + + +def test_loader_plan_output_path_and_command_line_shape_is_platform_consistent(): + """Regardless of platform, `os.path.join` used exactly once per Rust + `.join()` call reproduces the oracle -- on POSIX this is simply forward + slashes throughout, no divergence to pin.""" + output_path, command_line = _loader_plan( + "/proj", "/sdk", "/proj/board.yaml", "python3", "dts-overlay" + ) + assert output_path == os.path.join("/proj", "build/generated/alp.overlay") + assert "alp_project.py" in command_line + assert "--emit dts-overlay" in command_line + + +# --------------------------------------------------------------------------- +# build_trace_decisions +# --------------------------------------------------------------------------- + + +def test_decisions_carry_one_entry_per_target_plus_a_focus_entry(): + decisions = build_trace_decisions( + "/proj", "/sdk", "/proj/board.yaml", "python3", ("cmake-args",), "som.sku" + ) + assert [d["key"] for d in decisions] == [ + "generation.target.cmake-args", + "config.path.som.sku", + ] + assert decisions[0]["outcome"] == "planned" + assert decisions[0]["outputPath"].endswith("alp-cmake-args.txt") + assert "outputPath" not in decisions[1] # the focus decision carries none + assert decisions[1]["detail"] == ( + "Path-level tracing is currently static and reports planning context only." + ) + + +def test_no_focus_means_no_config_path_entry(): + decisions = build_trace_decisions( + "/proj", "/sdk", "/proj/board.yaml", "python3", BUILD_CONFIG_EMIT_MODES, None + ) + assert len(decisions) == 4 + assert all(d["key"].startswith("generation.target.") for d in decisions) + + +# --------------------------------------------------------------------------- +# End to end +# --------------------------------------------------------------------------- + + +def test_sdk_root_unresolved_is_a_validation_failure(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + write(tmp_path / "board.yaml", "x") + result = runner.invoke(app, ["trace", "--format", "json"]) + assert result.exit_code == 2 + doc = json.loads(result.stdout) + assert doc["ok"] is False + assert doc["project"] == {"root": None, "boardYaml": None} + assert "sdk" not in doc + assert doc["data"]["decisions"] == [] + assert doc["issues"] == [ + { + "code": "trace.sdk-root-unresolved", + "severity": "error", + "message": ( + "alp-sdk root is unresolved. Use --sdk-root, pin one with `tan sdk " + "switch `, or place the project near an alp-sdk checkout." + ), + } + ] + + +def test_missing_board_yaml_is_a_validation_failure_with_sdk_still_reported( + tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + result = runner.invoke(app, ["trace", "--sdk-root", str(sdk), "--format", "json"]) + assert result.exit_code == 2 + doc = json.loads(result.stdout) + assert doc["ok"] is False + # sdk WAS resolved -- still reported on this failure path, unlike project. + assert doc["sdk"]["sourceTier"] == "sdkRootFlag" + assert doc["project"] == {"root": None, "boardYaml": None} + assert doc["issues"][0]["code"] == "trace.board-yaml-missing" + + +def test_unknown_target_is_an_internal_failure_with_null_target(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + write(tmp_path / "board.yaml", "x") + result = runner.invoke( + app, ["trace", "--sdk-root", str(sdk), "--target", "bogus", "--format", "json"] + ) + assert result.exit_code == 5 + doc = json.loads(result.stdout) + assert doc["data"]["target"] is None + assert doc["issues"][0]["code"] == "trace.internal-failure" + assert "bogus" in doc["issues"][0]["message"] + + +def test_default_traces_all_four_targets(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + write(tmp_path / "board.yaml", "x") + result = runner.invoke(app, ["trace", "--sdk-root", str(sdk), "--format", "json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["ok"] is True + assert doc["data"]["target"] is None # null when more than one target ran + assert len(doc["data"]["decisions"]) == 4 + assert doc["issues"] == [] + + +def test_single_target_reports_target_and_one_decision(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + write(tmp_path / "board.yaml", "x") + result = runner.invoke( + app, ["trace", "--sdk-root", str(sdk), "--target", "cmake-args", "--format", "json"] + ) + doc = json.loads(result.stdout) + assert doc["data"]["target"] == "cmake-args" + assert len(doc["data"]["decisions"]) == 1 + + +def test_all_flag_is_inert_target_still_wins(tmp_path, monkeypatch): + """Measured against the oracle: `--target X --all` still narrows to `X`; + `--all` alone matches the bare default. `resolve_targets` never reads it.""" + monkeypatch.chdir(tmp_path) + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + write(tmp_path / "board.yaml", "x") + both = runner.invoke( + app, + ["trace", "--sdk-root", str(sdk), "--target", "cmake-args", "--all", "--format", "json"], + ) + assert json.loads(both.stdout)["data"]["target"] == "cmake-args" + + all_only = runner.invoke(app, ["trace", "--sdk-root", str(sdk), "--all", "--format", "json"]) + assert len(json.loads(all_only.stdout)["data"]["decisions"]) == 4 + + +def test_text_mode_decision_count_and_quiet(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + sdk = tmp_path / "alp-sdk" + sdk_at(sdk) + write(tmp_path / "board.yaml", "x") + verbose = runner.invoke(app, ["trace", "--sdk-root", str(sdk)]) + assert verbose.stdout == "" + assert "trace: decisions=4" in verbose.stderr + assert "[planned] generation.target.zephyr-conf" in verbose.stderr + + quiet = runner.invoke(app, ["trace", "--sdk-root", str(sdk), "--quiet"]) + assert "trace: decisions=4" in quiet.stderr + assert "generation.target" not in quiet.stderr + + +def test_a_bad_format_is_a_usage_error_not_a_traceback(): + result = runner.invoke(app, ["trace", "--format", "yaml"]) + assert result.exit_code == 2 + assert "Traceback" not in result.output diff --git a/python/tests/commands/test_validate_command.py b/python/tests/commands/test_validate_command.py index 1551925d..f6bb6366 100644 --- a/python/tests/commands/test_validate_command.py +++ b/python/tests/commands/test_validate_command.py @@ -160,24 +160,28 @@ def test_text_and_json_formats_are_unchanged(tmp_path, monkeypatch): assert envelope["data"]["outcome"] == "clean" -def test_validate_without_offline_is_runtime_failure_not_internal(tmp_path, monkeypatch): +def test_validate_without_offline_is_validation_failure_not_internal(tmp_path, monkeypatch): """A `tan validate` without `--offline`, on a project whose board.yaml EXISTS, cannot produce a real verdict -- the spawn path is not ported yet -- but that is not a tan bug, so it must not exit 5. - Exit 1 here is a deliberate DEFERRAL (tan-cli#262), NOT a match to the - oracle. An earlier revision of this docstring claimed the oracle returns 1 - for this case; that was never measured, and it is false. Measured on - `tan 0.4.1-dev` with `--format json`: board.yaml present but no SDK root - exits **2** `validate.sdk-root-unresolved`. Closing that gap needs the real - spawn path, which is what #262 tracks. What IS aligned with the oracle is - the missing-board.yaml guard -- see the test below.""" + tan-cli#262 (v0.6.0, TAKEN): exit 2, not exit 1. "No verdict available" is + still the VALIDATOR's problem, not a tan runtime crash -- alp-sdk-vscode + renders exit 2 as "warning" and exit 1 as "error", so a failing project + used to show red instead of yellow. This is a DELIBERATE divergence from + the oracle's own `Outcome::Failed -> RuntimeFailure` mapping + (`crates/tan-cli/src/commands/validate.rs:60`), not a parity claim -- see + `validate_cmd.py`'s module docstring for the full measured comparison. + Measured on `tan 0.4.1-dev` with `--format json`: board.yaml present but + no SDK root exits 2 `validate.sdk-root-unresolved` (a different guard this + port doesn't implement yet; not what this test pins). What IS aligned with + the oracle is the missing-board.yaml guard -- see the test below.""" monkeypatch.chdir(tmp_path) _write(tmp_path, "som:\n sku: E1M-AEN701\npreset: e1m-evk\n") result = runner.invoke(app, ["validate", "--format", "json"]) - assert result.exit_code == int(ExitCode.RUNTIME_FAILURE), result.output + assert result.exit_code == int(ExitCode.VALIDATION_FAILURE), result.output envelope = json.loads(result.output) - assert envelope["exitCode"] == int(ExitCode.RUNTIME_FAILURE) + assert envelope["exitCode"] == int(ExitCode.VALIDATION_FAILURE) assert [i["code"] for i in envelope["issues"]] == ["validate.spawn-not-implemented"] @@ -190,7 +194,13 @@ def test_missing_board_yaml_is_validation_failure_on_both_paths(tmp_path, monkey exit 2, `validate.board-yaml-missing`, `data.outcome == "failed"`. Until #262 the non-offline path answered "not ported yet" at exit 1 here -- which is the very first thing a brand-new user sees from `tan validate`, and the - one place a gratuitous divergence is most expensive.""" + one place a gratuitous divergence is most expensive. + + tan-cli#350: the exit code (2) and issue CODE + (`validate.board-yaml-missing`) are pinned here unchanged -- the fix for + #350 is wording-only (see the two tests directly below), a DELIBERATE + divergence from the oracle's message/verdict text, not from its exit + code or issue code.""" monkeypatch.chdir(tmp_path) for args in ( ["validate", "--format", "json"], @@ -204,6 +214,55 @@ def test_missing_board_yaml_is_validation_failure_on_both_paths(tmp_path, monkey assert [i["code"] for i in envelope["issues"]] == ["validate.board-yaml-missing"] +def test_missing_board_yaml_message_names_where_and_remedy(tmp_path, monkeypatch): + """tan-cli#350 defects 1+2: the old wording, + "board.yaml path could not be resolved or the file does not exist." (still + the oracle's, byte-identical), names no remedy. The message carried by + `issues[].code == validate.board-yaml-missing` (shared verbatim with text + mode) must now name WHERE tan looked and BOTH remedies every sibling + guard names for its own missing input (`tan init`, `--board-yaml + `) -- mirroring `doctor_cmd.py`'s own `board.yaml not found -- run + \\`tan init\\` or pass \\`--board-yaml \\`` wording for the identical + guard.""" + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["validate", "--format", "json"]) + assert result.exit_code == int(ExitCode.VALIDATION_FAILURE), result.output + envelope = json.loads(result.output) + message = envelope["issues"][0]["message"] + assert "./board.yaml" in message + assert "tan init" in message + assert "--board-yaml " in message + # This is not a "validation failure" -- nothing was validated. + assert "validation failure" not in message + + +def test_missing_board_yaml_text_mode_verdict_is_not_validation_failure(tmp_path, monkeypatch): + """tan-cli#350 defect 1: `validate` with no board.yaml at all must not + print "validate: validation failure" -- that VERDICT implies something + was checked and found wrong, but nothing was validated. Measured on the + oracle (`tan 0.4.1-dev`): byte-identical wrong wording, hence this is a + deliberate divergence, not a parity gap.""" + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["validate", "--offline"]) + assert result.exit_code == int(ExitCode.VALIDATION_FAILURE), result.output + assert "validate: no board.yaml to validate" in result.output + assert "validate: validation failure" not in result.output + assert "tan init" in result.output + + +def test_found_but_invalid_board_yaml_keeps_validation_failure_text(tmp_path, monkeypatch): + """The #350 fix is scoped to the missing-file guard only -- a board.yaml + that exists but does not fit the model is still, correctly, a + "validation failure": something WAS checked and found wrong. Regression + guard for the sibling branch touched by the fix above.""" + monkeypatch.chdir(tmp_path) + _write(tmp_path, "som: E1M-AEN701\n") + result = runner.invoke(app, ["validate", "--offline"]) + assert result.exit_code == int(ExitCode.VALIDATION_FAILURE), result.output + assert "validate: validation failure" in result.output + assert "no board.yaml to validate" not in result.output + + def test_validate_offline_unreadable_board_yaml_is_still_internal_failure(tmp_path, monkeypatch): """A genuine internal failure -- board.yaml exists but cannot be read -- is a real tan-can't-cope case, not a validation verdict, and must stay at diff --git a/python/tests/conformance/test_contract_envelopes.py b/python/tests/conformance/test_contract_envelopes.py index 3e638042..4039cc04 100644 --- a/python/tests/conformance/test_contract_envelopes.py +++ b/python/tests/conformance/test_contract_envelopes.py @@ -90,6 +90,48 @@ NOT_PORTED = { } +#: Fixtures where the Python port DELIBERATELY does more than the frozen Rust +#: oracle, so the shared golden cannot describe both sides at once. +#: +#: This is the opposite direction from :data:`NOT_PORTED` above -- there the +#: port does LESS -- and it is why these five are declared rather than simply +#: regenerated. The golden is the CROSS-LANGUAGE contract: the same file holds +#: the Rust binary via ``crates/tan-cli/tests/contract.rs``, and ``crates/`` is +#: frozen. Regenerating it to match the port turns the Rust conformance run red +#: and quietly redefines "the contract" as "whatever the port last emitted". +#: A deliberate divergence has to be DECLARED, not written into the shared file. +#: (Measured: regenerating these five reddened ``test (ubuntu-latest)``, +#: ``test (macos-latest)`` and ``test (windows-latest)`` on the PR.) +#: +#: All five are tan-cli#138. ``create_launch_draft`` restores the v0.3.1 +#: ``preLaunchTask`` default for the three build target kinds, which the frozen +#: oracle had made opt-in in tan-cli#85. alp-sdk-vscode contributes task +#: providers for exactly those labels and never passes ``--pre-launch-task``, +#: so without the default its contribution is dead and build-then-debug +#: silently stops happening. ``yocto-userspace`` is here only because its +#: fixture asserts the whole envelope as one document; that target deliberately +#: gains NO default -- see ``tan.core.debug_launch.DEFAULT_PRE_LAUNCH_TASK`` +#: for why naming its task would put an error dialog in front of every F5. +#: +#: ``strict=True`` for the same reason as above, and it carries more weight +#: here: an XPASS means the divergence VANISHED -- someone reverted the #138 +#: restoration -- which is a regression that must fail loudly rather than +#: quietly re-green the suite. +DELIBERATE_DIVERGENCE = { + "debug-config-preview-zephyr-mcu": "tan-cli#138: restores the v0.3.1 preLaunchTask default", + "debug-config-preview-zephyr-mcu-sdk-identity": ( + "tan-cli#138: restores the v0.3.1 preLaunchTask default" + ), + "debug-config-preview-baremetal-mcu": ( + "tan-cli#138: restores the v0.3.1 preLaunchTask default" + ), + "debug-config-preview-native-host": "tan-cli#138: restores the v0.3.1 preLaunchTask default", + "debug-config-preview-yocto-userspace": ( + "tan-cli#138: sibling of the four above -- this target gains NO default, but its " + "fixture asserts the whole envelope and the harness compares it as one document" + ), +} + def normalise(value, key, work_dir_marker): """Scoped ``\\`` -> ``/`` plus ``__WORKDIR__`` substitution on path-shaped @@ -137,17 +179,33 @@ def copy_fixture_inputs(case_dir, work_dir): shutil.copy2(entry, work_dir / entry.name) +def _marks_for(case: str) -> list: + """The xfail marks for one case, from the two declared-exception maps. + + Both are `strict=True`, so an entry that stops applying FAILS rather than + silently reporting XPASS -- see each map's own comment. A case may appear + in only one: `NOT_PORTED` means the port does less, `DELIBERATE_DIVERGENCE` + means it does more, and both at once would be incoherent. + """ + if case in NOT_PORTED and case in DELIBERATE_DIVERGENCE: + raise AssertionError( + f"{case} is declared in BOTH NOT_PORTED and DELIBERATE_DIVERGENCE; " + "a case cannot be simultaneously unported and deliberately ahead" + ) + if case in NOT_PORTED: + return [pytest.mark.xfail(reason=NOT_PORTED[case], strict=True)] + if case in DELIBERATE_DIVERGENCE: + return [pytest.mark.xfail(reason=DELIBERATE_DIVERGENCE[case], strict=True)] + return [] + + @pytest.mark.parametrize( "fixture", [ pytest.param( f, id=f.name, - marks=( - [pytest.mark.xfail(reason=NOT_PORTED[f.name], strict=True)] - if f.name in NOT_PORTED - else [] - ), + marks=_marks_for(f.name), ) for f in FIXTURES ], diff --git a/python/tests/conformance/test_packaged_binary.py b/python/tests/conformance/test_packaged_binary.py index c5df93b0..eb0319d6 100644 --- a/python/tests/conformance/test_packaged_binary.py +++ b/python/tests/conformance/test_packaged_binary.py @@ -1,10 +1,22 @@ # SPDX-License-Identifier: Apache-2.0 -"""The packaged artifact must satisfy the extension's own probe: a single file -whose ``--version`` first line matches /^tan \\d+\\.\\d+\\.\\d+/, answering inside -the extension's 3 s budget (alp-sdk-vscode/src/alpCli/vscodeAdapter.ts:288-290). - -Skips when ``dist/tan[.exe]`` is absent so the normal suite is unaffected; run -``scripts/build_binary.sh`` to produce it. +"""The packaged artifact must satisfy the extension's own probe: ``--version`` +first line matches /^tan \\d+\\.\\d+\\.\\d+/, answering inside the extension's +3 s budget (alp-sdk-vscode/src/alpCli/vscodeAdapter.ts:288-290). + +From tan-cli#349 the shipped shape is a PyInstaller --onedir freeze, archived +for release, not a single raw binary: ``dist/tan/`` is the unpacked folder +(``dist/tan/tan[.exe]`` + ``dist/tan/_internal/``) and ``dist/tan.zip`` / +``dist/tan.tar.gz`` is the archive `scripts/build_binary.sh` actually ships. +Running ``--version`` against the already-unpacked folder (rather than +unpacking the archive first) is deliberate: it is what install.sh hands the +launcher script it writes, and what the extension's own unpack step (a +SEPARATE unit of #349, on the alp-sdk-vscode side) will hand the resolved +binary path -- the archive itself is inert until something has unpacked it, +and testing that unpack step is not this file's job. + +Skips when neither ``dist/tan/tan[.exe]`` nor a quarantined archive is present +so the normal suite is unaffected; run ``scripts/build_binary.sh`` to produce +them. """ import json import re @@ -17,10 +29,14 @@ import pytest PYTHON_ROOT = Path(__file__).resolve().parents[2] -BINARY = PYTHON_ROOT / "dist" / ("tan.exe" if sys.platform == "win32" else "tan") -#: Where `scripts/build_binary.sh` moves an artifact that broke its ceiling, so -#: that no consumer can `cp` it -- `exit 1` alone is defeatable by a pipe. -QUARANTINE = BINARY.with_name(BINARY.name + ".oversized") +DIST_DIR = PYTHON_ROOT / "dist" / "tan" +BINARY = DIST_DIR / ("tan.exe" if sys.platform == "win32" else "tan") +ARCHIVE_EXT = "zip" if sys.platform == "win32" else "tar.gz" +ARCHIVE = PYTHON_ROOT / "dist" / f"tan.{ARCHIVE_EXT}" +#: Where `scripts/build_binary.sh` moves the ARCHIVE (not the onedir folder) +#: when it breaks its ceiling, so that no consumer can `cp` it -- `exit 1` +#: alone is defeatable by a pipe. +QUARANTINE = ARCHIVE.with_name(ARCHIVE.name + ".oversized") pytestmark = pytest.mark.skipif( not BINARY.exists() and not QUARANTINE.exists(), @@ -83,21 +99,28 @@ def _refuse_a_quarantined_build(): ) -def test_artifact_is_a_single_file(): - # --onedir would hand the extension a directory it has no unpack step for - # (alp-sdk-vscode/src/alpCli/download.ts:159-162 writes the body to ONE path). - assert BINARY.is_file(), "must be --onefile: the extension cannot unpack a directory" +def test_artifact_ships_as_onedir_plus_one_archive(): + # tan-cli#349: --onedir, not --onefile -- the folder IS the deliverable now, + # and the archive built from it (never the folder itself) is the one thing + # every downstream consumer (checksums.txt, install.sh, the extension's own + # unpack step) deals with as a single file. + assert DIST_DIR.is_dir(), f"{DIST_DIR} must be the --onedir folder" + assert BINARY.is_file(), f"{BINARY} missing from the onedir folder" + if not QUARANTINE.exists(): + assert ARCHIVE.is_file(), f"{ARCHIVE} missing -- build_binary.sh archives dist/tan/ into one file" def test_artifact_was_built_from_a_clean_interpreter(): # The 3 s probe below does NOT catch a dirty build: an artifact built off an # interpreter carrying numpy/Pillow/pywin32 measured 34349423 B and ~1.00 s # -- 3x the size and 2x the startup, still comfortably green. Size is the - # only signal separating the two. - size = BINARY.stat().st_size + # only signal separating the two. Measured over the ARCHIVE (what + # `scripts/build_binary.sh` ceiling-checks and what actually ships), not the + # unpacked folder -- see that script's own comment on why. + size = ARCHIVE.stat().st_size ceiling = _max_artifact_bytes() assert size < ceiling, ( - f"{BINARY} is {size} B against a {ceiling} B ceiling -- likely built " + f"{ARCHIVE} is {size} B against a {ceiling} B ceiling -- likely built " f"from a dirty interpreter that pulled in modules tan never imports; " f"see scripts/build_binary.sh. If the venv is clean, measure and edit " f"scripts/artifact_ceilings.env (both readers share it)." @@ -147,6 +170,59 @@ def test_version_probe_completes_within_the_3s_budget(): print(f"\nstartup: {elapsed:.3f}s") +def test_version_probe_stays_within_the_onedir_budget(): + """A dedicated, TIGHT regression gate for tan-cli#349 -- separate from the + 3 s test above, which merely enforces the extension's actual probe + timeout and is loose enough that a --onefile-style regression could land + and still pass it silently: measured on THIS host, a --onefile build of + the same commit answers --version in 0.833-0.875 s (10 runs, mean + 0.855 s) -- comfortably under the 3 s test, so that test alone would not + have caught the regression even on the machine sitting right here. (Only + macOS actually missed the 3 s budget outright, on the unsigned + re-extracted-dylib verification cost: 13.25-19.74 s, measured on the + published v0.5.0-rc4 asset -- this suite has no macOS runner to reproduce + that number with.) + + Threshold is picked from THIS repo's own onedir measurement, checked + against the same host's --onefile build rather than inferred: + + onedir (this build): 10 runs, 0.274-0.340 s, mean 0.294 s + onefile (same commit): 10 runs, 0.833-0.875 s, mean 0.855 s + + 0.6 s sits in the ~0.49 s gap between them -- about 1.8x the onedir + ceiling above its own worst run (room for a slower/loaded CI box) while + staying a comfortable 0.23 s under the onefile floor, so a build that + silently reverts to --onefile fails THIS test on this very platform, not + only on macOS's much larger margin. Re-verified directly against a real + --onefile build of this binary before picking the number (the tan-cli#323 + lesson: prove a regression gate against the known-bad build, don't infer + it would fail from a different platform's numbers). A future change that + reintroduces per-invocation extraction trips this gate long before it + would threaten the extension's own 3 s timeout, or even get near it -- + which is the point: the 3 s test alone was not tight enough to have + caught the original regression, and the e2e harness (getting-started.yml) + asserts correctness only, never speed. + """ + start = time.monotonic() + subprocess.run( + [str(BINARY), "--version"], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + elapsed = time.monotonic() - start + assert elapsed < 0.6, ( + f"--version took {elapsed:.2f}s -- over the 0.6s onedir budget " + f"(measured local baseline: 0.27-0.34s over 10 runs; a --onefile " + f"build of the same commit measured 0.83-0.88s, well above this " + f"threshold). This is the tan-cli#349 regression gate: something in " + f"this build re-added per-invocation extraction cost -- check " + f"scripts/build_binary.sh is still building --onedir, not --onefile." + ) + print(f"\nonedir startup: {elapsed:.3f}s") + + def test_the_artifact_carries_its_scaffold_templates(tmp_path): """`tan init`'s vendored scaffold trees are DATA, so PyInstaller's static import graph does not reach them -- they ship only because diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 82d57c3c..e589bd74 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -10,7 +10,9 @@ developer who follows the documented `export ALP_SDK_ROOT=` onboarding, or who has ever run `tan sdk switch --global`, gets a DIFFERENT SDK resolved than a clean CI runner would, and a test asserting "nothing -resolves" observes a real checkout instead. +resolves" observes a real checkout instead. `ZEPHYR_BASE` gets the same +treatment for the same reason: a developer/CI shell's own value must not +decide what a test observes any more than `ALP_SDK_ROOT` does. Autouse, function-scoped, and applied to every test in this tree -- both in-process calls (`resolve_sdk_root_ladder` et al., called directly) and the @@ -86,6 +88,13 @@ def sdk_root() -> Path | None: @pytest.fixture(autouse=True) def _scrub_sdk_discovery_env(tmp_path_factory, monkeypatch): monkeypatch.delenv("ALP_SDK_ROOT", raising=False) + # A developer/CI shell's own `$ZEPHYR_BASE` must not decide what a test + # observes any more than `ALP_SDK_ROOT` does -- left unscrubbed, a test + # asserting "no ZEPHYR_BASE workspace resolved" instead sees a real one. + # `test_native_sim_e2e.py` needs the REAL value for its actual `west + # build` subprocess; it reads that from `REAL_ENVIRON` above, not from + # `os.environ` inside the test body, for exactly this reason. + monkeypatch.delenv("ZEPHYR_BASE", raising=False) # `SOURCE_DATE_EPOCH` wins over the clock in `tan.core.timestamp`, so a # developer or CI image that exports it (reproducible-build setups do) # changes every `generatedAt`/`updatedAt` this suite observes -- and a diff --git a/python/tests/core/test_bootstrap.py b/python/tests/core/test_bootstrap.py new file mode 100644 index 00000000..a1b16b8c --- /dev/null +++ b/python/tests/core/test_bootstrap.py @@ -0,0 +1,94 @@ + + +def test_the_oracle_first_line_stays_byte_identical_and_the_remedy_is_a_second_line(): + """tan-cli#355 is a DELIBERATE divergence, and this pins its exact shape so + it cannot drift into an accidental one. + + `bootstrap.sh` prints one line and nothing else -- note the TWO spaces + before "Install", which a reflow would silently eat. tan keeps that line + byte for byte and adds a SECOND naming `tan doctor --build --fix`, which is + the installer tan-cli#91 gave tan and which the original wording predates. + + Fails if someone restores the oracle's silence (the remedy line vanishes), + and equally if someone "tidies" the first line and breaks the parity it is + the whole point of preserving.""" + from tan.core.bootstrap import posix_refusal + + failure = posix_refusal(["cmake", "ninja", "xz", "wget"], {}) + lines = failure.lines if hasattr(failure, "lines") else failure[1] + + assert len(lines) == 2, lines + # Byte-identical to the oracle, TWO spaces included. + assert lines[0] == "Missing required tools: cmake ninja xz wget. Install them and re-run." + assert " Install them" in lines[0], "the oracle's double space was reflowed away" + # The remedy tan actually ships. + assert "tan doctor --build --fix" in lines[1] + + +#: alp-sdk `dev`'s real `prerequisites.install.linux`, transcribed. Every entry +#: needs elevation, which is the whole point of tan-cli#370. +_LINUX_INSTALL = { + "git": "sudo apt-get install -y git", + "cmake": "sudo apt-get install -y cmake", + "python3": "sudo apt-get install -y python3", + "ninja": "sudo apt-get install -y ninja-build", + "xz": "sudo apt-get install -y xz-utils", + "wget": "sudo apt-get install -y wget", +} + +#: alp-sdk `dev`'s real `prerequisites.install.macos`. No elevation anywhere. +_MACOS_INSTALL = { + "cmake": "brew install cmake", + "ninja": "brew install ninja", +} + + +def test_the_remedy_does_not_promise_an_install_it_cannot_perform(): + """tan-cli#370. `doctor --build --fix` REFUSES to spawn any command whose + first word is `sudo` (`doctor_cmd.fix_needs_sudo_check`) -- under + `--format json` this process's stdio is captured end to end, so a password + prompt would hang forever rather than fail loudly. Every one of alp-sdk's + `prerequisites.install.linux` commands starts with `sudo`, so on Linux + `--fix` installs nothing and prints instead. + + tan-cli#355's original second line said "to install them from the SDK's + manifest", which is therefore false on the host most customers are on -- + replacing one wrong expectation with another, which is exactly what #355 + set out to stop. + + Fails against the pre-#370 wording, which was a single constant.""" + from tan.core.bootstrap import posix_refusal + + lines = posix_refusal(["cmake", "ninja", "xz", "wget"], _LINUX_INSTALL).lines + + # The oracle's line is untouched by any of this. + assert lines[0] == "Missing required tools: cmake ninja xz wget. Install them and re-run." + assert "tan doctor --build --fix" in lines[1] + assert "prints the exact command" in lines[1] + assert "sudo" in lines[1], "the reason it cannot install has to be the reason given" + assert "to install them from the SDK's manifest" not in lines[1] + + +def test_the_remedy_still_promises_an_install_where_none_needs_elevation(): + """The other half of tan-cli#370: macOS is POSIX, and `brew install ...` + needs no elevation, so `--fix` really does install there. Keying the wording + on the PLATFORM rather than on the commands would have made this case wrong + in the opposite direction.""" + from tan.core.bootstrap import posix_refusal + + lines = posix_refusal(["cmake", "ninja"], _MACOS_INSTALL).lines + + assert lines[1] == ( + "Or run `tan doctor --build --fix` to install them from the SDK's manifest." + ) + + +def test_a_tool_the_manifest_has_no_command_for_does_not_imply_elevation(): + """A missing tool the manifest cannot install contributes generic advice, + never a spawn -- so it must not tip the wording toward the elevation + variant. Guards the `install.get(tool, "")` default.""" + from tan.core.bootstrap import posix_refusal + + lines = posix_refusal(["cmake", "dtc"], {"cmake": "brew install cmake"}).lines + + assert "to install them from the SDK's manifest" in lines[1] diff --git a/python/tests/core/test_consent.py b/python/tests/core/test_consent.py new file mode 100644 index 00000000..c2db487e --- /dev/null +++ b/python/tests/core/test_consent.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#91: `tan.core.consent.can_prompt` — the one gate standing between +`doctor --fix` and unattended host mutation. + +This suite exists because the hand-written copy of this check inside +`doctor_cmd.py` shipped with **three of its five conditions**, omitting both +`isatty()` calls, and the entire `doctor --fix` test suite stayed green through +three independent mutations of it — including deleting the guard outright. + +So every test below is written to FAIL against a specific way of getting this +wrong, and the truth-table test is exhaustive over all 32 combinations rather +than sampling the ones that happen to be convenient. +""" +from __future__ import annotations + +import itertools +import sys + +import pytest + +from tan.core.consent import can_prompt + + +class _FakeStream: + def __init__(self, tty: bool) -> None: + self._tty = tty + + def isatty(self) -> bool: + return self._tty + + +@pytest.fixture +def ttys(monkeypatch): + """Set `sys.stdin`/`sys.stderr` tty-ness independently. + + `monkeypatch.setattr` on `sys.stdin` is the only honest way to drive this: + under pytest both handles are already replaced by capture objects whose + `isatty()` is `False`, so a test that did NOT patch them would pass the + all-flags-clear case for the wrong reason — it would be measuring pytest's + capture, not the function. + """ + + def _set(*, stdin: bool, stderr: bool) -> None: + monkeypatch.setattr(sys, "stdin", _FakeStream(stdin)) + monkeypatch.setattr(sys, "stderr", _FakeStream(stderr)) + + return _set + + +def test_all_conditions_met_is_the_only_true_case(ttys): + ttys(stdin=True, stderr=True) + assert can_prompt(non_interactive=False, ci=False, json_mode=False) is True + + +@pytest.mark.parametrize( + "non_interactive,ci,json_mode,stdin_tty,stderr_tty", + [c for c in itertools.product([False, True], repeat=5) if c != (False, False, False, True, True)], +) +def test_every_other_combination_is_false( + ttys, non_interactive, ci, json_mode, stdin_tty, stderr_tty +): + """Exhaustive over all 32 combinations: exactly one is `True`. + + A sampled test lets a wrong implementation through — the shipped + `doctor --fix` bug was invisible precisely because no case in its suite + combined "flags all clear" with "stdio is not a terminal", which is the + single most common shape of an automated run. + """ + ttys(stdin=stdin_tty, stderr=stderr_tty) + assert ( + can_prompt(non_interactive=non_interactive, ci=ci, json_mode=json_mode) is False + ) + + +def test_a_redirected_stderr_alone_withholds_consent(ttys): + """`tan doctor --fix 2>log`: stdin can carry the answer, but the question + would go to a file the user never reads, so tan would block on a prompt + nobody saw. This is the half a stdin-only check misses.""" + ttys(stdin=True, stderr=False) + assert can_prompt(non_interactive=False, ci=False, json_mode=False) is False + + +def test_a_redirected_stdin_alone_withholds_consent(ttys): + """`tan doctor --fix < /dev/null`: the question can be asked, but no + answer can ever arrive.""" + ttys(stdin=False, stderr=True) + assert can_prompt(non_interactive=False, ci=False, json_mode=False) is False + + +def test_fully_captured_pipes_withhold_consent_even_with_no_flags(ttys): + """The exact shape that caused tan-cli#91's live incident: a CI runner + that captures both streams and does NOT pass `--ci` or + `--non-interactive`. The shipped guard returned "yes, prompt" here and + spawned four real `winget install` runs.""" + ttys(stdin=False, stderr=False) + assert can_prompt(non_interactive=False, ci=False, json_mode=False) is False + + +def test_stdout_tty_ness_is_deliberately_not_consulted(monkeypatch, ttys): + """`tan doctor --fix | tee log` from a real terminal is a normal, + fully-interactive invocation. Consulting `stdout` would refuse consent + with the human sitting right there, so the result must not move when + `stdout` changes.""" + ttys(stdin=True, stderr=True) + monkeypatch.setattr(sys, "stdout", _FakeStream(False)) + assert can_prompt(non_interactive=False, ci=False, json_mode=False) is True + monkeypatch.setattr(sys, "stdout", _FakeStream(True)) + assert can_prompt(non_interactive=False, ci=False, json_mode=False) is True diff --git a/python/tests/core/test_debug_launch.py b/python/tests/core/test_debug_launch.py new file mode 100644 index 00000000..c98dd4d1 --- /dev/null +++ b/python/tests/core/test_debug_launch.py @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#138 / tan-cli#321: the pre-launch-task default/override/opt-out +three-state contract, and the yocto-userspace `--gdbserver-address` +resolution -- the pure-logic half of `tan.core.debug_launch`, exercised +directly (no subprocess, no envelope). The command-level wiring (the CLI +flags, the tan-cli#321 info issue, the "no effect" notes) is covered by +`tests/commands/test_debug_config_command.py`. +""" +from __future__ import annotations + +import json + +import pytest + +from tan.core.debug_launch import ( + BAREMETAL_MCU, + DEFAULT_PRE_LAUNCH_TASK, + GDBSERVER, + JLINK, + NATIVE_HOST, + OPENOCD, + PYOCD, + SERVER_NONE, + YOCTO_USERSPACE, + ZEPHYR_MCU, + LaunchResolution, + apply_launch_resolution, + create_launch_draft, +) + +#: Every (target, server) pair paired with the v0.3.1 default that target +#: restores (tan-cli#138). The default is keyed by TARGET alone, so +#: zephyr-mcu's three servers all share one string; see +#: `test_the_baremetal_default_is_the_same_across_every_server` for the same +#: invariant on the two baremetal servers left implicit here. +#: +#: `YOCTO_USERSPACE` is deliberately ABSENT: three of the four target classes +#: get a restored default, not four (the #138-vs-#321 resolution -- +#: `DEFAULT_PRE_LAUNCH_TASK`'s own doc comment in `tan/core/debug_launch.py` +#: has the full quotation). `test_yocto_userspace_gets_no_default_pre_launch_ +#: task` below covers that target on its own. +DEFAULTED_PROFILES = [ + (ZEPHYR_MCU, JLINK, "alp: build active target"), + (ZEPHYR_MCU, OPENOCD, "alp: build active target"), + (ZEPHYR_MCU, PYOCD, "alp: build active target"), + (BAREMETAL_MCU, JLINK, "alp: build baremetal target"), + (NATIVE_HOST, SERVER_NONE, "alp: build native_sim target"), +] + + +def test_default_pre_launch_task_table_is_the_v031_literals(): + """`DEFAULT_PRE_LAUNCH_TASK` keyed by TARGET alone -- three target + classes, not four, each holding the exact v0.3.1 string + (`crates/tan-core/src/debug_launch.rs` before tan-cli#85 made the key + opt-in). `YOCTO_USERSPACE` is deliberately absent: alp-sdk-vscode + registers no working task for it (the only one that exists exits 1 by + design), so restoring that one label would put the "preLaunchTask + terminated with exit code 1" dialog in front of every F5 -- the + #138-vs-#321 resolution `DEFAULT_PRE_LAUNCH_TASK`'s own doc comment + records.""" + assert DEFAULT_PRE_LAUNCH_TASK == { + ZEPHYR_MCU: "alp: build active target", + BAREMETAL_MCU: "alp: build baremetal target", + NATIVE_HOST: "alp: build native_sim target", + } + assert YOCTO_USERSPACE not in DEFAULT_PRE_LAUNCH_TASK + + +# Formerly `no_profile_names_a_pre_launch_task_by_default` +# (`crates/tan-core/src/debug_launch.rs`): that Rust test pinned "no default +# preLaunchTask" as the Bug-1 regression fix (tan-cli#85). tan-cli#138 is a +# MAINTAINER DECISION that inverts the intent for three of the four target +# classes -- alp-sdk-vscode has since registered those three labels as real +# tasks, so the v0.3.1 defaults are restored for them -- so THIS is the +# corrected assertion for those five profiles, not a new, unrelated test. Its +# Rust sibling still asserts the old, now superseded, behaviour: `crates/` is +# a frozen oracle this port no longer tracks (see +# `python/tan/commands/debug_config_cmd.py`'s module docstring). +@pytest.mark.parametrize("target,server,expected_task", DEFAULTED_PROFILES) +def test_every_profile_names_its_v031_pre_launch_task_by_default(target, server, expected_task): + draft = create_launch_draft(target, server, None) + assert draft["preLaunchTask"] == expected_task + # Belt and braces, mirroring the Rust test's own: a `null` would still + # serialize as a key, so absence from the rendered JSON is the real proof. + assert '"preLaunchTask"' in json.dumps(draft) + + +def test_yocto_userspace_gets_no_default_pre_launch_task(): + """The flip side of `test_default_pre_launch_task_table_is_the_v031_ + literals` at the draft level: yocto-userspace's only registered task + exits 1 by design (alp-sdk-vscode#406), so a plain run with no + `--pre-launch-task` must NOT reach for a default the way the other three + targets do -- the trailing `del` in `create_launch_draft` fires on `None` + here exactly as it did for every target before tan-cli#138 restored the + other three. + + An explicit `--pre-launch-task ''` reaches the identical `del`, not a + distinct path, now that yocto-userspace has no default entry left to opt + out of -- checked here too rather than only via the trimmed + `DEFAULTED_PROFILES` parametrize above, which no longer names this + target at all. + """ + draft = create_launch_draft(YOCTO_USERSPACE, GDBSERVER, None) + assert "preLaunchTask" not in draft + assert '"preLaunchTask"' not in json.dumps(draft) + + draft_explicit_empty = create_launch_draft(YOCTO_USERSPACE, GDBSERVER, "") + assert "preLaunchTask" not in draft_explicit_empty + + +def test_the_baremetal_default_is_the_same_across_every_server(): + """The task's own six-line table names only baremetal-mcu+jlink; the + default is keyed by TARGET alone (tan-cli#138), so OpenOCD and pyOCD + baremetal profiles must carry the identical string, not their own.""" + for server in (OPENOCD, PYOCD): + draft = create_launch_draft(BAREMETAL_MCU, server, None) + assert draft["preLaunchTask"] == "alp: build baremetal target" + + +def test_an_opted_in_pre_launch_task_overrides_the_default_verbatim(): + """The `--pre-launch-task ` override, unchanged by tan-cli#138: any + non-empty string wins over the restored default, emitted in place.""" + draft = create_launch_draft(ZEPHYR_MCU, JLINK, "alpRun: build") + assert draft["preLaunchTask"] == "alpRun: build" + # …in its ORIGINAL position, not appended at the end -- the key order the + # module docstring calls contract. + keys = list(draft.keys()) + assert keys.index("preLaunchTask") == keys.index("runToEntryPoint") + 1 + + +@pytest.mark.parametrize("target,server,_expected", DEFAULTED_PROFILES) +def test_an_empty_string_opts_out_of_the_restored_default(target, server, _expected): + """tan-cli#138's explicit opt-out: `--pre-launch-task ''` must still reach + the trailing `del` in `create_launch_draft` -- now that every target has a + non-`None` default, `None` alone can no longer get there; this is the one + remaining way to drop the key. `drop_absent_pre_launch_task`'s Python twin + stays dead code without a caller that reaches it, which this is.""" + draft = create_launch_draft(target, server, "") + assert "preLaunchTask" not in draft + assert '"preLaunchTask"' not in json.dumps(draft) + + +def test_apply_launch_resolution_fills_the_gdbserver_address_placeholder(): + """tan-cli#321 direction 2: `--gdbserver-address` is the ONLY source of + `miDebuggerServerAddress`'s resolution -- nothing else (a build, SDK- + published metadata) can ever know where the board ends up after deploy.""" + draft = create_launch_draft(YOCTO_USERSPACE, GDBSERVER, None) + assert draft["miDebuggerServerAddress"] == ":" + + apply_launch_resolution(draft, LaunchResolution(gdbserver_address="192.168.10.42:3333")) + + assert draft["miDebuggerServerAddress"] == "192.168.10.42:3333" + + +def test_apply_launch_resolution_leaves_the_placeholder_with_no_address_given(): + draft = create_launch_draft(YOCTO_USERSPACE, GDBSERVER, None) + apply_launch_resolution(draft, LaunchResolution()) + assert draft["miDebuggerServerAddress"] == ":" + + +def test_gdbserver_address_has_no_effect_on_a_draft_without_the_key(): + """The same "only replace a key the draft already carries" rule + `apply_launch_resolution`'s own docstring states for every other field -- + a zephyr-mcu draft has no `miDebuggerServerAddress` key to fill.""" + draft = create_launch_draft(ZEPHYR_MCU, JLINK, None) + apply_launch_resolution(draft, LaunchResolution(gdbserver_address="192.168.10.42:3333")) + assert "miDebuggerServerAddress" not in draft diff --git a/python/tests/core/test_global_flags.py b/python/tests/core/test_global_flags.py new file mode 100644 index 00000000..62ff8722 --- /dev/null +++ b/python/tests/core/test_global_flags.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Unit tests for `tan.core.global_flags.accept_global_flags` -- the +tan-cli#261 mechanism. The port-wide behavioural gate lives at +`tests/gates/test_global_flags_gate.py`; these test the MECHANISM itself in +isolation, including the PEP 563 regression it was built to survive +(`explain --target zephyr-board` crashing with `RuntimeError: Type not yet +supported: str` the first time this function wrapped a module with `from +__future__ import annotations`, measured before the fix below existed). +""" +from __future__ import annotations + +import typer +from typer.main import get_command +from typer.testing import CliRunner + +from tan.core.global_flags import GLOBAL_FLAG_ARITY, GLOBAL_FLAGS, accept_global_flags + +runner = CliRunner() + + +def _make_app(command_func) -> typer.Typer: + """A real two-command Typer app -- Typer collapses a SINGLE registered + command straight to a bare `click.Command` (no group, no subcommand + name), which is not the shape any real tan command runs under; a second, + unrelated command keeps this a `Group` the way `tan.cli.app`'s real 32 + commands do.""" + app = typer.Typer(add_completion=False) + app.command("probe")(command_func) + + def _other() -> None: + typer.echo("other") + + app.command("other")(_other) + return app + + +def test_global_flags_and_arity_stay_in_lockstep(): + assert set(GLOBAL_FLAGS) == set(GLOBAL_FLAG_ARITY) + for flag in GLOBAL_FLAGS: + assert GLOBAL_FLAG_ARITY[flag] in (0, 1) + + +def test_injects_every_missing_flag_and_drops_them_before_the_command_runs(): + seen: dict[str, object] = {} + + def probe() -> None: + seen["ran"] = True + + wrapped = accept_global_flags(probe) + app = _make_app(wrapped) + + argv = ["probe"] + for flag in GLOBAL_FLAGS: + argv.append(flag) + if GLOBAL_FLAG_ARITY[flag] == 1: + argv.append("some-value") + + result = runner.invoke(app, argv) + assert result.exit_code == 0, result.output + assert seen.get("ran") is True + + +def test_a_flag_already_declared_under_a_different_python_name_is_not_duplicated(): + """`--all` under the python name `all_cores` (the real name `clean_cmd` + uses) must be recognised as ALREADY covering `--all` -- detected by the + CLI flag string itself, not by the Python parameter name, which varies + command to command for the identical flag.""" + calls: list[bool] = [] + + def probe( + all_cores: bool = typer.Option(False, "--all", help="the command's own --all"), + ) -> None: + calls.append(all_cores) + + wrapped = accept_global_flags(probe) + app = _make_app(wrapped) + + result = runner.invoke(app, ["probe", "--all"]) + assert result.exit_code == 0, result.output + assert calls == [True], "the command's OWN --all must still be the one that ran" + + # And the pre-existing flag was not silently swallowed by a second, + # injected `--all` shadowing it: passing it exactly once still reaches + # the real parameter with the real value. + result = runner.invoke(app, ["probe"]) + assert result.exit_code == 0, result.output + assert calls[-1] is False + + +def test_a_command_declaring_the_full_set_is_returned_unchanged(): + def probe( + project: str = typer.Option(None, "--project"), + board_yaml: str = typer.Option(None, "--board-yaml"), + sdk_root: str = typer.Option(None, "--sdk-root"), + target: str = typer.Option(None, "--target"), + all_: bool = typer.Option(False, "--all"), + verbose: bool = typer.Option(False, "--verbose"), + quiet: bool = typer.Option(False, "--quiet"), + no_color: bool = typer.Option(False, "--no-color"), + non_interactive: bool = typer.Option(False, "--non-interactive"), + ci: bool = typer.Option(False, "--ci"), + ) -> None: + pass + + assert accept_global_flags(probe) is probe + + +def test_pep563_stringised_annotations_on_the_original_parameters_still_resolve(): + """The tan-cli#261 regression: every real `*_cmd.py` module has `from + __future__ import annotations` at the top, which makes + `inspect.signature(func).parameters[name].annotation` a bare STRING + (`'str'`), not the type `str`. A first version of `accept_global_flags` + copied those Parameter objects verbatim into the wrapper's `__signature__` + and Typer could no longer resolve them (it looks in the WRAPPER's + `__globals__`, not the original function's) -- `RuntimeError: Type not + yet supported: str` on every command that had even one PRE-EXISTING + string-typed option, discovered via `explain --target zephyr-board`. + This module reproduces that shape directly (its own + `from __future__ import annotations`, at the top) rather than special- + casing it away.""" + + seen: dict[str, object] = {} + + def probe( + existing: str = typer.Option(None, "--existing", metavar="TEXT"), + ) -> None: + seen["existing"] = existing + + wrapped = accept_global_flags(probe) + app = _make_app(wrapped) + + # Building the click Command at all is the crash site -- unwrapped, this + # raises `RuntimeError: Type not yet supported: str` if the annotation + # was left as the literal string instead of being resolved. + get_command(app) + + result = runner.invoke(app, ["probe", "--existing", "hello", "--verbose"]) + assert result.exit_code == 0, result.output + assert seen["existing"] == "hello" diff --git a/python/tests/core/test_module_template.py b/python/tests/core/test_module_template.py new file mode 100644 index 00000000..2cc7b953 --- /dev/null +++ b/python/tests/core/test_module_template.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan.core.module_template` -- the module-scaffold registry, name +normalization, and file-content generators `tan scaffold` (#260) plans +against. Every assertion below was cross-checked against the frozen Rust +oracle (`target/debug/tan.exe scaffold --format json ...`), not derived from +reading `crates/tan-core/src/wizard/service/module_scaffold.rs` alone. +""" + +import pytest + +from tan.core.module_template import ( + DEFAULT_MODULE_TEMPLATE_ID, + MODULE_TEMPLATE_IDS, + create_module_scaffold_plan, + list_module_templates, + normalize_module_name, + plan_module_files, +) + + +def test_registry_order_and_ids_match_the_oracle(): + # `ModuleTemplateId::as_str` order, `wizard/models.rs`. + assert MODULE_TEMPLATE_IDS == ( + "sensor-driver", + "connectivity-service", + "inference-stage", + "diagnostics-check", + ) + assert [d.id for d in list_module_templates()] == list(MODULE_TEMPLATE_IDS) + + +def test_default_template_is_the_first_registry_entry(): + # `resolve_template`'s non-interactive arm hardcodes `SensorDriver` + # (`crates/tan-cli/src/commands/scaffold.rs`) -- the registry's first id, + # unlike `tan init`'s own default (which is NOT its first template). + assert DEFAULT_MODULE_TEMPLATE_ID == MODULE_TEMPLATE_IDS[0] == "sensor-driver" + + +# --------------------------------------------------------------------------- +# normalize_module_name +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("my-conn", "my_conn"), + ("My Sensor!", "my_sensor"), + (" a1_b2 ", "a1_b2"), + ("___", None), # every char is a separator -> empty -> ValueError + # Measured against the oracle: the accented `é` and the double dash + # beside it collapse into ONE separator run, not two underscores. + ("Héllo--World123", "h_llo_world123"), + ], +) +def test_normalize_module_name_matches_the_oracle(raw, expected): + if expected is None: + with pytest.raises(ValueError, match="empty after normalization"): + normalize_module_name(raw) + else: + assert normalize_module_name(raw) == expected + + +def test_normalize_module_name_never_leaves_a_leading_or_trailing_separator(): + assert normalize_module_name("--leading and trailing--") == "leading_and_trailing" + + +# --------------------------------------------------------------------------- +# File-content generators +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("template_id", MODULE_TEMPLATE_IDS) +def test_every_template_plans_the_same_three_paths_in_order(template_id): + plan = create_module_scaffold_plan(template_id, "my_mod") + paths = [f.relative_path for f in plan.files] + # Exact order the oracle's `data.fileChanges[]` lists them in. + assert paths == [ + "include/modules/my_mod.h", + "src/modules/my_mod/my_mod.c", + "src/modules/my_mod/README.md", + ] + assert plan.template_id == template_id + assert plan.normalized_name == "my_mod" + + +def test_connectivity_service_content_matches_the_oracle_byte_for_byte(): + """Pinned against `target/debug/tan.exe --format json scaffold --name + my-conn --template connectivity-service` (measured, not read from + source): the exact bytes a customer's module lands with.""" + plan = create_module_scaffold_plan("connectivity-service", "my-conn") + by_path = {f.relative_path: f.content for f in plan.files} + + assert by_path["include/modules/my_conn.h"] == ( + "// SPDX-License-Identifier: Apache-2.0\n" + "\n" + "#ifndef ALP_MODULES_MY_CONN_H\n" + "#define ALP_MODULES_MY_CONN_H\n" + "\n" + "int alp_conn_my_conn_init(void);\n" + "int alp_conn_my_conn_run(void);\n" + "\n" + "#endif /* ALP_MODULES_MY_CONN_H */\n" + ) + assert by_path["src/modules/my_conn/my_conn.c"] == ( + "// SPDX-License-Identifier: Apache-2.0\n" + "\n" + '#include "modules/my_conn.h"\n' + "\n" + "// Board context: unavailable\n" + "\n" + "int alp_conn_my_conn_init(void) {\n" + " // TODO: initialize module dependencies.\n" + " return 0;\n" + "}\n" + "\n" + "int alp_conn_my_conn_run(void) {\n" + " // TODO: implement module main behavior.\n" + " return 0;\n" + "}\n" + ) + assert by_path["src/modules/my_conn/README.md"] == ( + "# Alp Module Scaffold\n" + "\n" + "Template: connectivity-service\n" + "Module: my_conn\n" + "\n" + "## Notes\n" + "\n" + "- Use my_conn_init for stack/session initialization.\n" + "- Keep retry/backoff and transport health checks localized in this module.\n" + "\n" + "Generated by Alp: Scaffold module.\n" + ) + + +def test_readme_substitutes_nm_into_every_explanation_line(): + definition = next(d for d in list_module_templates() if d.id == "sensor-driver") + files = plan_module_files(definition, "tmp112") + readme = next(f for f in files if f.relative_path.endswith("README.md")) + assert "tmp112_run" in readme.content + assert "{nm}" not in readme.content + + +def test_create_module_scaffold_plan_raises_on_an_unnormalizable_name(): + with pytest.raises(ValueError, match="empty after normalization"): + create_module_scaffold_plan("sensor-driver", "!!!") diff --git a/python/tests/core/test_renode_sim.py b/python/tests/core/test_renode_sim.py new file mode 100644 index 00000000..1810b63c --- /dev/null +++ b/python/tests/core/test_renode_sim.py @@ -0,0 +1,344 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan.core.renode_sim` unit tests -- ported from +`crates/tan-core/src/renode/sim.rs`'s own `#[cfg(test)]` module, which is the +oracle for every case here. Several of these were additionally cross-checked +live: driving the shipped `tan.exe` oracle's `--sim-mode` control socket over +a real TCP connection produced the exact same replies pinned below (a +`WriteBytes` -> `ok`, a `ReadBytes` -> lowercase space-separated `0xnn` +tokens, a bare command -> `ok`).""" +from __future__ import annotations + +import pytest + +from tan.core.renode_sim import ( + SimError, + MonitorLine, + build_sim_descriptor, + build_sim_renode_argv, + build_sim_resc_text, + classify_monitor_line, + dispatch_control_line, + normalize_readbytes_output, + parse_int_auto, + ready_marker, + sim_profile_deferred_message, + translate_control_command, +) + + +# ── sim-descriptor.json ────────────────────────────────────────────────────── + + +def test_descriptor_has_exactly_the_four_schema_keys_in_order(): + d = build_sim_descriptor(40001, 40002) + assert list(d.keys()) == ["control_socket", "uart_socket", "framebuffers", "peripherals"] + assert d["control_socket"] == "tcp://127.0.0.1:40001" + assert d["uart_socket"] == "tcp://127.0.0.1:40002" + assert d["framebuffers"] == [] + assert d["peripherals"] == [] + + +def test_descriptor_serialises_with_the_socket_uri_scheme(): + import json + + text = json.dumps(build_sim_descriptor(1, 65535)) + assert '"tcp://127.0.0.1:1"' in text + assert '"tcp://127.0.0.1:65535"' in text + + +# ── generated boot script + argv ───────────────────────────────────────────── + + +def test_resc_boots_headless_platform_elf_start(): + text = build_sim_resc_text("/m/p.repl", "/b/fw.elf", None) + assert 'mach create "v2n_sim"' in text + assert "machine LoadPlatformDescription @/m/p.repl" in text + assert "sysbus LoadELF @/b/fw.elf" in text + assert text.rstrip("\n").endswith("start") + # Deferred half: no wired-UART socket terminal yet. + assert "CreateServerSocketTerminal" not in text + assert "connector Connect" not in text + # No vtor -> no write at all (Renode keeps its own guess). + assert "0xE000ED08" not in text + # The generated script NEVER includes the SDK's own `.resc` (the plain + # path's `i @...`). + assert "i @" not in text + + +def test_resc_seeds_the_secure_vtor_after_loadelf_and_before_start(): + text = build_sim_resc_text("p.repl", "fw.elf", 0x0800_3000) + assert "sysbus WriteDoubleWord 0xE000ED08 0x8003000" in text + load = text.find("LoadELF") + vtor = text.find("0xE000ED08") + start = text.rfind("start") + assert load < vtor < start + + +def test_sim_argv_is_the_exact_headless_contract(): + argv = build_sim_renode_argv("/opt/renode/renode", "/b/.sim-boot.resc") + assert argv == [ + "/opt/renode/renode", + "--disable-xwt", + "--plain", + "--console", + "-e", + "i @/b/.sim-boot.resc", + ] + assert "--hide-monitor" not in argv + + +# ── ReadBytes normalisation ─────────────────────────────────────────────────── + + +def test_normalize_readbytes_lowercases_and_flattens_the_bracketed_list(): + out = "[\n0xDE, 0xAD, 0xBE, 0xEF, \n]\n" + assert normalize_readbytes_output(out, 4) == "0xde 0xad 0xbe 0xef" + + +def test_normalize_readbytes_ignores_the_echoed_command_address(): + # Regression: the echoed `sysbus ReadBytes 0x20000000 4` line carries + # 0x20000000, which masks to 0x00 -- it must NOT leak in as a byte. + out = "sysbus ReadBytes 0x20000000 4\n[\n0xDE, 0xAD, 0xBE, 0xEF, \n]\n" + assert normalize_readbytes_output(out, 4) == "0xde 0xad 0xbe 0xef" + + +def test_normalize_readbytes_short_read_is_an_error_never_padded(): + with pytest.raises(SimError) as excinfo: + normalize_readbytes_output("[ 0x01, 0x02, ]", 4) + assert "expected 4" in str(excinfo.value) + + +def test_normalize_readbytes_masks_wide_tokens_to_their_low_byte(): + assert ( + normalize_readbytes_output("[ 0xDEAD, 0x5, 0x1234567890ABCDEF12 ]", 3) + == "0xad 0x05 0x12" + ) + + +def test_normalize_readbytes_falls_back_to_the_whole_output_without_brackets(): + assert normalize_readbytes_output("0x41 0x42", 2) == "0x41 0x42" + + +# ── control-line translation (the three verbs) ─────────────────────────────── + + +def test_translate_readbytes_forwards_verbatim_and_carries_the_count(): + count, cmds = translate_control_command("sysbus ReadBytes 0x1000 8") + assert count == 8 + assert cmds == ["sysbus ReadBytes 0x1000 8"] + + +def test_translate_writebytes_expands_to_ordered_lowercase_writebyte(): + count, cmds = translate_control_command("sysbus WriteBytes 0x20000000 0xde 0xad 0xbe 0xef") + assert count is None + assert cmds == [ + "sysbus WriteByte 0x20000000 0xde", + "sysbus WriteByte 0x20000001 0xad", + "sysbus WriteByte 0x20000002 0xbe", + "sysbus WriteByte 0x20000003 0xef", + ] + + +def test_translate_writebytes_masks_oversized_bytes(): + _count, cmds = translate_control_command("sysbus WriteBytes 0x100 0x1de 256") + assert cmds == ["sysbus WriteByte 0x100 0xde", "sysbus WriteByte 0x101 0x0"] + + +def test_translate_rejects_a_writebytes_with_no_data(): + with pytest.raises(SimError, match="no data bytes"): + translate_control_command("sysbus WriteBytes 0x20000000") + + +def test_translate_rejects_malformed_bases_and_counts(): + with pytest.raises(SimError, match="^malformed WriteBytes"): + translate_control_command("sysbus WriteBytes zzz 0xde") + with pytest.raises(SimError, match="^malformed ReadBytes"): + translate_control_command("sysbus ReadBytes 0x1000 xx") + # Signed tokens are rejected rather than masked (documented divergence). + with pytest.raises(SimError, match="^malformed WriteBytes"): + translate_control_command("sysbus WriteBytes -1 0xde") + + +def test_translate_accepts_decimal_and_the_other_python_radices(): + count, _cmds = translate_control_command("sysbus ReadBytes 4096 4") + assert count == 4 + _count, cmds = translate_control_command("sysbus WriteBytes 0o20 0b1010") + assert cmds == ["sysbus WriteByte 0x10 0xa"] + + +def test_documented_int_token_divergences_from_python_hold(): + # A leading-zero decimal: accepted as 10 here, `int("010", 0)` raises. + assert parse_int_auto("010") == 10 + # PEP 515 digit separators: rejected here, `int("1_0", 0)` is 10. + assert parse_int_auto("1_0") is None + with pytest.raises(SimError, match="^malformed ReadBytes"): + translate_control_command("sysbus ReadBytes 0x1000 1_0") + + +def test_a_writebytes_address_overflow_names_the_arithmetic_not_a_token(): + with pytest.raises(SimError) as excinfo: + translate_control_command("sysbus WriteBytes 0xFFFFFFFFFFFFFFFF 0x1 0x2") + text = str(excinfo.value) + assert text.startswith("malformed WriteBytes") + assert "overflows a 64-bit address" in text + assert "invalid integer token" not in text + + +def test_translate_forwards_an_inject_template_verbatim(): + line = "sysbus.iic8.i2c_tmp112 Temperature 85" + count, cmds = translate_control_command(line) + assert count is None + assert cmds == [line] + + +# ── the deferred-profile warning + the readiness marker ───────────────────── + + +def test_the_deferred_profile_warning_states_the_empty_arrays_and_silent_uart(): + m = sim_profile_deferred_message("E1M-V2N101") + assert "E1M-V2N101" in m + assert "framebuffers" in m + assert "peripherals" in m + assert "BOTH empty" in m + assert "streams NOTHING" in m + assert "tan-cli#77" in m + assert "WIRED hardware UART" not in m + + +def test_the_deferred_profile_warning_fires_for_aen801_and_names_its_wired_console(): + m = sim_profile_deferred_message("E1M-AEN801") + assert "E1M-AEN801" in m + assert "BOTH empty" in m + assert "WIRED hardware UART" in m + assert "deferred as well" in m + + +def test_the_ready_marker_carries_the_consumers_poll_token(): + line = ready_marker(60) + assert "ready (timeout" in line + assert line == "tan renode --sim-mode: ready (timeout 60s)." + + +# ── the full control-socket dispatch (the retired e2e's four assertions) ──── + + +class _FakeMonitor: + """A fake Renode monitor: a byte-addressed memory plus a property store, + answering the same monitor vocabulary the bridge emits. Port of + `crates/tan-core/src/renode/sim.rs`'s own `FakeMonitor` test double.""" + + def __init__(self) -> None: + self.mem: dict[int, int] = {} + self.props: dict[str, str] = {} + + def command(self, cmd: str) -> str: + parts = cmd.split() + if len(parts) == 4 and parts[0] == "sysbus" and parts[1] == "WriteByte": + addr, val = int(parts[2], 0), int(parts[3], 0) & 0xFF + self.mem[addr] = val + return "" # a write prints nothing + if len(parts) == 4 and parts[0] == "sysbus" and parts[1] == "ReadBytes": + addr, count = int(parts[2], 0), int(parts[3], 0) + body = ", ".join(f"0x{self.mem.get(addr + i, 0):02X}" for i in range(count)) + return f"{cmd}\n[\n{body}, \n]\n" + if len(parts) == 3: + node, prop, value = parts + self.props[f"{node} {prop}"] = value + return "" # a property SET prints nothing + if len(parts) == 2: + node, prop = parts + return self.props.get(f"{node} {prop}", "") + raise RuntimeError(f"No such command {cmd!r}") + + +def test_control_socket_round_trips_the_four_end_to_end_assertions(): + fake = _FakeMonitor() + + def cmd(line: str) -> str: + return dispatch_control_line(line, fake.command) + + # 1. a WriteBytes replies `ok` (per-byte WriteByte prints nothing). + assert cmd("sysbus WriteBytes 0x08010000 0xde 0xad 0xbe 0xef") == "ok" + # 2. the ReadBytes reply is lowercase, space-separated, one line. + assert cmd("sysbus ReadBytes 0x08010000 4") == "0xde 0xad 0xbe 0xef" + # 3. an inject (property SET) replies `ok`. + assert cmd("sysbus.iic8.i2c_tmp112 Temperature 85") == "ok" + # 4. a property GET echoes the value back. + assert "85" in cmd("sysbus.iic8.i2c_tmp112 Temperature") + + +def test_a_malformed_line_replies_err_and_never_panics(): + fake = _FakeMonitor() + reply = dispatch_control_line("sysbus WriteBytes 0x100", fake.command) + assert reply.startswith("ERR ") + assert "no data bytes" in reply + + +def test_a_monitor_error_replies_err_carrying_the_reason(): + def run(_line: str) -> str: + raise RuntimeError("Renode monitor is unusable") + + assert dispatch_control_line("bogus", run) == "ERR Renode monitor is unusable" + + +def test_a_short_read_replies_err_rather_than_a_padded_answer(): + def run(_line: str) -> str: + return "[ 0x01, 0x02, ]" + + reply = dispatch_control_line("sysbus ReadBytes 0x100 8", run) + assert reply.startswith("ERR ") + assert "expected 8" in reply + + +def test_every_reply_is_exactly_one_line(): + def multi_ok(_line: str) -> str: + return "a\nb\nc" + + def multi_err(_line: str) -> str: + raise RuntimeError("line one\nline two") + + def multi_readbytes(_line: str) -> str: + return "[\n0x1,\n0x2,\n]" + + for reply in [ + dispatch_control_line("get thing", multi_ok), + dispatch_control_line("get thing", multi_err), + dispatch_control_line("sysbus ReadBytes 0x1 2", multi_readbytes), + ]: + assert "\n" not in reply + assert "\r" not in reply + + +# ── monitor line classification ────────────────────────────────────────────── + + +def test_only_the_bare_sentinel_terminates_a_command(): + sent = "__ALP_SIM_DONE_7__" + cmd = "sysbus ReadBytes 0x1000 4" + assert classify_monitor_line(sent, sent, cmd) is MonitorLine.DONE + assert classify_monitor_line(f" {sent} ", sent, cmd) is MonitorLine.DONE + # The echoed INPUT carries the sentinel but is not it -- dropping it is + # what keeps `echo "..."` out of the captured output. + assert classify_monitor_line(f'echo "{sent}"', sent, cmd) is MonitorLine.IGNORE + + +def test_errors_surface_and_info_warning_and_the_command_echo_do_not(): + sent = "__ALP_SIM_DONE_1__" + cmd = "sysbus WriteByte 0x0 0x1" + assert ( + classify_monitor_line("12:00:00.1 [ERROR] sysbus: no peripheral", sent, cmd) + is MonitorLine.ERROR + ) + # [ERROR] is checked BEFORE [INFO]/[WARNING]: a line carrying both must + # still surface as an error. + assert classify_monitor_line("[INFO] and [ERROR] together", sent, cmd) is MonitorLine.ERROR + assert ( + classify_monitor_line("12:00:00.1 [INFO] machine: started", sent, cmd) + is MonitorLine.IGNORE + ) + assert ( + classify_monitor_line("12:00:00.1 [WARNING] cpu: slow", sent, cmd) is MonitorLine.IGNORE + ) + assert classify_monitor_line(cmd, sent, cmd) is MonitorLine.IGNORE + assert classify_monitor_line(f"(monitor) {cmd}", sent, cmd) is MonitorLine.IGNORE + assert classify_monitor_line("[\n0xDE, ]", sent, cmd) is MonitorLine.OUTPUT diff --git a/python/tests/core/test_scaffold.py b/python/tests/core/test_scaffold.py index a2109da8..25fa6f05 100644 --- a/python/tests/core/test_scaffold.py +++ b/python/tests/core/test_scaffold.py @@ -32,10 +32,18 @@ vendored_core_ids, write_files, ) +from tan.planner_root import bind_sdk_root from tan.templates import VENDORED_ROOT +from tests.conftest import sdk_root WINDOWS = os.name == "nt" +#: A real alp-sdk checkout, for the one test below that runs the REAL planner +#: helper (`_zephyr_app_dir`) rather than re-implementing its rule. Read at +#: MODULE level -- see `tests/conftest.py::sdk_root`'s own docstring on why a +#: call from inside a test body always sees it already scrubbed. +SDK = sdk_root() + def _make_dir_link(link: Path, target: Path) -> bool: """A directory link `link` -> `target`: a Windows JUNCTION (no elevated @@ -113,6 +121,80 @@ def test_minimal_app_plans_the_eight_files_the_golden_pins(): ] +def test_minimal_app_cmake_is_a_real_zephyr_app_and_reaches_every_source(): + """tan-cli#309: `minimal-app`'s `board.yaml` declares `os: zephyr`, but + through v0.5.0-rc3 the file `west build` actually configured + (`src/CMakeLists.txt` -- see `test_minimal_app_board_yaml_app_resolves_ + to_the_directory_the_planner_actually_configures` below for WHY it was + that file and not the root one) called plain `add_executable(alp_app + ...)`, no `find_package(Zephyr ...)` anywhere in either CMake file. CMake + configures and links that shape fine, so `tan build` exited 0 for a + project that was never Zephyr at all. + + Content only, and only half the defect -- this pins the CMake *shape*; + `test_minimal_app_board_yaml_app_resolves_to_the_directory_the_planner_ + actually_configures` below pins that `board.yaml` actually points `west + build` AT this shape, closing the gap that made the shape-only version of + this test pass while the real end-to-end repro (tan-cli#309's own + adversarial review) still failed. Against the reverted (pre-#309) + generator this fails at the first assertion (no `find_package(Zephyr` in + the root file at all); the second block below never even reaches an + assert against that generator -- `src_cmake.index("target_sources(app")` + raises `ValueError: substring not found`, since the old `src/ + CMakeLists.txt` had no such call. + + `test_minimal_app_plans_the_eight_files_the_golden_pins` above already + pins the (unchanged) file list `contract/envelopes/ + init-preview-minimal-app/expected.json` pins on the wire. + """ + files = {f.relative_path: f.content for f in plan_template_files("minimal-app", DEFAULT_SOM_SKU)} + root_cmake = files["CMakeLists.txt"] + src_cmake = files["src/CMakeLists.txt"] + + assert "find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})" in root_cmake + # `find_package(Zephyr ...)` must run before `project()` -- it resolves the + # toolchain/board machinery `project()` consumes when it enables the C + # language (the same order every vendored template's own CMakeLists.txt + # uses); `project()` itself has no dependency on Zephyr's `app` target. + assert root_cmake.index("find_package(Zephyr") < root_cmake.index("project(") + + # Every file `_minimal_app_files` plans under `src/` must be reachable from + # a `target_sources(app ...)` call -- not a second `add_executable`, which + # Zephyr's build never links in. + sources_block = src_cmake[src_cmake.index("target_sources(app") :] + assert "add_executable(" not in root_cmake + src_cmake + for path in ("main.c", "features/app_bootstrap.c"): + assert path in sources_block, f"{path} not reachable from target_sources(app ...): {src_cmake}" + + +@pytest.mark.skipif(SDK is None, reason="set ALP_SDK_ROOT/ALP_SDK_PARITY_ROOT to a real alp-sdk checkout") +def test_minimal_app_board_yaml_app_resolves_to_the_directory_the_planner_actually_configures(tmp_path): + """tan-cli#309's real blocker, closed against the REAL planner helper, not + a reimplementation of its rule: `_zephyr_app_dir` + (`tan/planner/orchestrator.py`) resolves `board.yaml`'s `app:` to whichever + of that path or its PARENT holds a `CMakeLists.txt`, preferring the path + itself. This template's `src/` deliberately keeps a `CMakeLists.txt` of + its own, so `app: ./src` (the pre-#309 value) resolved to `src/` -- the + file with no `find_package(Zephyr ...)` -- and the real root file + (`test_minimal_app_cmake_is_a_real_zephyr_app_and_reaches_every_source` + above) was never reached by the planner at all. `test_minimal_app_plans_ + the_eight_files_the_golden_pins` cannot catch this -- it never reads + `app:`'s value, and neither language's contract fixture pins it (see + `MANIFEST.md`).""" + files = plan_template_files("minimal-app", DEFAULT_SOM_SKU) + write_files(tmp_path, files) + board_yaml = next(f.content for f in files if f.relative_path == "board.yaml") + assert " app: .\n" in board_yaml, board_yaml + + bind_sdk_root(SDK) + from tan.planner.orchestrator import _zephyr_app_dir # noqa: PLC0415 -- must import AFTER bind_sdk_root, see planner_root.py + + app_dir = _zephyr_app_dir(".", tmp_path) + + assert app_dir == tmp_path + assert "find_package(Zephyr" in (app_dir / "CMakeLists.txt").read_text(encoding="utf-8") + + def test_app_core_follows_the_som_family(): assert app_core_for_sku("E1M-V2N101") == "m33_sm" assert app_core_for_sku("E1M-V2M101") == "m33_sm" diff --git a/python/tests/core/test_setools.py b/python/tests/core/test_setools.py new file mode 100644 index 00000000..df5495ae --- /dev/null +++ b/python/tests/core/test_setools.py @@ -0,0 +1,467 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan.core.setools` -- tan-cli#353's remaining half: SETOOLS `app-gen-toc` +integration for the AEN801 Flow D slot0 sign step. + +Every subprocess spawn here drives a FAKE `app-gen-toc` -- a script this file +writes, never a real SETOOLS install (license-gated, not redistributed, and +not required to prove the wiring: see `tan/commands/doctor_cmd.py`'s own +`setools` check, which treats a real install as Linux-only). `.bat` on +Windows, a POSIX shebang script elsewhere -- picked because a batch-content +file with NO extension is not directly spawnable via `subprocess.run(..., +shell=False)` (measured: `WinError 193`), while a POSIX shebang script is +spawnable extension-less. `sign_slot0` itself takes an explicit +`app_gen_toc` path, so most tests never need `find_app_gen_toc`'s own +bare-name lookup at all; the one test that drives the FULL +`resolve -> find -> sign` path monkeypatches `APP_GEN_TOC` for the Windows +case only, so `find_app_gen_toc`'s real lookup logic still runs, just against +the one filename this host can actually execute. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from tan.core.flash_plan import FlashPlanError +from tan.core import setools as setools_module +from tan.core.setools import ( + SetoolsSource, + find_app_gen_toc, + missing_tool_message, + read_atoc_address, + resolve_setools_dir, + sign_slot0, + slot0_config, + unresolved_message, +) + +#: The maintainer's own measured value (tan-cli#353) -- kept verbatim rather +#: than a made-up placeholder, so a fixture typo can never look plausible. +_REAL_ATOC_ADDRESS = "0x8057ea50" + + +# ── resolve_setools_dir ────────────────────────────────────────────────────── + + +def test_resolve_setools_dir_precedence_is_flag_then_env_then_manifest(): + """tan-cli#368: `--setools-dir` outranks `SETOOLS_DIR`, which outranks + `flash_args.setools_dir` -- the OPPOSITE of most `flash_args` accessors in + this codebase, and deliberately so: the manifest field is rebuilt over by + every `tan build`, so it is the LEAST durable of the three, not the most. + All three set, with all three DIFFERENT, proves the full chain in one + call each.""" + all_three = resolve_setools_dir( + {"setools_dir": "/from/manifest"}, {"SETOOLS_DIR": "/from/env"}, "/from/flag" + ) + assert all_three == SetoolsSource("/from/flag", "the --setools-dir flag") + + flag_absent = resolve_setools_dir( + {"setools_dir": "/from/manifest"}, {"SETOOLS_DIR": "/from/env"}, None + ) + assert flag_absent == SetoolsSource("/from/env", "the SETOOLS_DIR environment variable") + + +def test_resolve_setools_dir_falls_back_to_the_manifest_field(): + resolved = resolve_setools_dir({"setools_dir": "/from/manifest"}, {}) + assert resolved == SetoolsSource("/from/manifest", "flash_args.setools_dir") + + +def test_resolve_setools_dir_is_none_when_none_of_the_three_is_set(): + assert resolve_setools_dir({}, {}) is None + assert resolve_setools_dir({"setools_dir": ""}, {"SETOOLS_DIR": ""}, "") is None + + +def test_resolve_setools_dir_ignores_a_non_string_flash_args_value(): + """A malformed `flash_args` (e.g. the SDK's `TBD` placeholder, or a bare + `setools_dir: true`) must fall through to the env var, not raise -- + `fa_str` already treats non-string as absent, and this is not a + behaviour-affecting field worth a stricter accessor.""" + resolved = resolve_setools_dir({"setools_dir": True}, {"SETOOLS_DIR": "/from/env"}) + assert resolved == SetoolsSource("/from/env", "the SETOOLS_DIR environment variable") + assert resolve_setools_dir("TBD", {}) is None + + +# ── find_app_gen_toc ───────────────────────────────────────────────────────── + + +def test_find_app_gen_toc_finds_the_bare_name(tmp_path): + (tmp_path / setools_module.APP_GEN_TOC).write_text("", encoding="utf-8") + found = find_app_gen_toc(str(tmp_path)) + assert found == str(tmp_path / setools_module.APP_GEN_TOC) + + +def test_find_app_gen_toc_is_none_when_absent(tmp_path): + assert find_app_gen_toc(str(tmp_path)) is None + + +def test_find_app_gen_toc_is_none_for_a_hostile_path(): + """A NUL byte or similar must read as "not found", never raise -- this + runs on customer-supplied paths (`--setools-dir`, `flash_args.setools_dir` + or an env var).""" + assert find_app_gen_toc("bad\x00path") is None + + +def test_find_app_gen_toc_also_tries_the_exe_suffix_on_windows(tmp_path, monkeypatch): + """tan-cli#369: a genuine Windows SETOOLS install ships `app-gen-toc.exe`, + and the bare-name-only lookup never found it -- `missing_tool_message` + then told a real Windows customer their install "did not look like" one. + `os.name` is faked to `"nt"` so the `.exe` branch is exercised on every + host running this suite, not only on Windows -- `os.path.isfile` itself + is unaffected by `os.name` (resolved once at interpreter start, not + re-dispatched per call), so this only exercises the candidate list.""" + monkeypatch.setattr(os, "name", "nt") + exe = tmp_path / f"{setools_module.APP_GEN_TOC}.exe" + exe.write_text("", encoding="utf-8") + assert find_app_gen_toc(str(tmp_path)) == str(exe) + + +# ── guidance messages -- remedy first, blame never ────────────────────────── + + +def test_unresolved_message_names_the_remedy(): + msg = unresolved_message() + assert "SETOOLS" in msg + assert "license-gated" in msg + assert "app-gen-toc" in msg + assert "--setools-dir" in msg + assert "SETOOLS_DIR=" in msg + assert "flash_args.setools_dir" in msg + # Precedence order, flag first (tan-cli#368). + assert msg.index("--setools-dir") < msg.index("SETOOLS_DIR=") + assert msg.index("SETOOLS_DIR=") < msg.index("flash_args.setools_dir") + # No blame: never says the customer did anything wrong. + assert "you " not in msg.lower() + + +def test_missing_tool_message_names_the_source(): + source = SetoolsSource("/opt/bad-install", "flash_args.setools_dir") + msg = missing_tool_message(source) + assert "/opt/bad-install" in msg + assert "flash_args.setools_dir" in msg + assert "app-gen-toc" in msg + + +def test_missing_tool_message_names_what_was_checked_not_a_conclusion(tmp_path): + """tan-cli#369: no more "this does not look like an Alif Security Toolkit + install" verdict -- the message must name the exact candidate path(s) + tried, and say so differently depending on whether `setools.path` is + even a real directory.""" + # A directory that genuinely does not exist. + missing = SetoolsSource(str(tmp_path / "nope"), "the SETOOLS_DIR environment variable") + msg = missing_tool_message(missing) + assert "does not look like an Alif Security Toolkit install" not in msg + assert "not a directory at all" in msg + assert setools_module.APP_GEN_TOC in msg + + # A path pointed at the app-gen-toc BINARY itself, not its parent. + binary_path = tmp_path / setools_module.APP_GEN_TOC + binary_path.write_text("", encoding="utf-8") + pointed_at_binary = SetoolsSource(str(binary_path), "flash_args.setools_dir") + msg = missing_tool_message(pointed_at_binary) + assert "PARENT directory" in msg + + # A real directory that simply holds no app-gen-toc. + (tmp_path / "empty").mkdir() + empty_dir = SetoolsSource(str(tmp_path / "empty"), "--setools-dir") + msg = missing_tool_message(empty_dir) + assert "the directory exists but holds none of them" in msg + + +# ── slot0_config ───────────────────────────────────────────────────────────── + + +def test_slot0_config_matches_the_measured_bench_shape(): + """The exact shape the AEN801 bench flow signs by hand (tan-cli#353) -- + no top-level "DEVICE" key (see the module docstring: an app-only ATOC + must not overwrite the on-module factory device config).""" + config = slot0_config("m55_he", "m55_he.bin", "0x80010000", "M55_HE") + assert config == { + "m55_he": { + "binary": "m55_he.bin", + "version": "1.0.0", + "mramAddress": "0x80010000", + "cpu_id": "M55_HE", + "flags": ["boot"], + "signed": True, + } + } + assert "DEVICE" not in config + + +# ── read_atoc_address ──────────────────────────────────────────────────────── + + +def test_read_atoc_address_parses_a_real_report(tmp_path): + build = tmp_path / "build" + build.mkdir() + (build / "app-package-map.txt").write_text( + f"Device Algorithm Package\nAPP Package Start Address: {_REAL_ATOC_ADDRESS}\n", + encoding="utf-8", + ) + assert read_atoc_address(str(tmp_path)) == _REAL_ATOC_ADDRESS + + +def test_read_atoc_address_is_none_when_the_report_is_missing(tmp_path): + assert read_atoc_address(str(tmp_path)) is None + + +# ── sign_slot0 -- the real (fake) app-gen-toc spawn ───────────────────────── + + +def _script_name() -> str: + """`.bat` on Windows (needs the extension to be directly spawnable, see + the module docstring), the real bare name elsewhere.""" + return "app-gen-toc.bat" if os.name == "nt" else setools_module.APP_GEN_TOC + + +def _write_fake_app_gen_toc( + dest: Path, + *, + exit_code: int = 0, + map_line: str | None = f"APP Package Start Address: {_REAL_ATOC_ADDRESS}", + write_blob: bool = True, + stderr_text: str = "", + append: bool = False, +) -> str: + """A fake `app-gen-toc` at `dest`, genuinely spawnable on THIS host with + no `shell=True` -- it writes `build/app-package-map.txt` (with or + without the marker line) and `build/AppTocPackage.bin` under its OWN + cwd (`sign_slot0` always spawns with `cwd=setools_dir`, matching the + bench's own `cd $SETOOLS_DIR && ./app-gen-toc ...`), then exits + `exit_code`. Proves the WIRING, not a real SETOOLS. + + `append` (tan-cli#373): the real `app-gen-toc` behaviour + `parse_atoc_start_address`'s docstring documents -- ADDS a fresh block to + `app-package-map.txt` rather than truncating it. `False` (the default) + matches every OTHER test here, which starts from an empty/absent map and + so cannot tell append from overwrite; `True` is for the one test that + specifically proves a PRIOR entry survives a real sign untouched.""" + redirect = ">>" if append else ">" + if os.name == "nt": + lines = ["@echo off", "if not exist build mkdir build"] + if map_line is not None: + lines.append(f"{redirect}build\\app-package-map.txt echo {map_line}") + else: + lines.append("type nul > build\\app-package-map.txt") + if write_blob: + lines.append("echo fake-atoc-bytes> build\\AppTocPackage.bin") + if stderr_text: + lines.append(f"echo {stderr_text} 1>&2") + lines.append(f"exit /b {exit_code}") + dest.write_text("\r\n".join(lines) + "\r\n", encoding="utf-8") + else: + lines = ["#!/bin/sh", "mkdir -p build"] + if map_line is not None: + lines.append(f'printf "%s\\n" "{map_line}" {redirect} build/app-package-map.txt') + else: + lines.append(": > build/app-package-map.txt") + if write_blob: + lines.append('printf "fake-atoc-bytes\\n" > build/AppTocPackage.bin') + if stderr_text: + lines.append(f'echo "{stderr_text}" >&2') + lines.append(f"exit {exit_code}") + dest.write_text("\n".join(lines) + "\n", encoding="utf-8") + os.chmod(dest, 0o755) + return str(dest) + + +def _write_noop_app_gen_toc(dest: Path, *, exit_code: int = 0) -> str: + """A fake `app-gen-toc` that touches NOTHING under its cwd -- simulates a + SOFT FAILURE (tan-cli#365): a real spawn that exits 0 without actually + (re)writing `build/app-package-map.txt` / `build/AppTocPackage.bin`. + Whatever those already held before the spawn is left completely + untouched, so a caller that trusts their post-spawn presence alone would + happily report a PREVIOUS run's stale ATOC as this run's result.""" + if os.name == "nt": + dest.write_text(f"@echo off\r\nexit /b {exit_code}\r\n", encoding="utf-8") + else: + dest.write_text(f"#!/bin/sh\nexit {exit_code}\n", encoding="utf-8") + os.chmod(dest, 0o755) + return str(dest) + + +def _artefact_bin(tmp_path: Path) -> Path: + artefact = tmp_path / "zephyr.bin" + artefact.write_bytes(b"fake-app-image-bytes") + return artefact + + +def test_sign_slot0_copies_writes_and_derives_the_address(tmp_path): + """The end-to-end happy path: copy the raw `.bin` into + `build/images/.bin`, write `build/config/-slot0.json`, run + `app-gen-toc`, and return the derived `(atoc_path, atoc_address)` -- + tan-cli#353's requirement (a).""" + setools_dir = tmp_path / "setools" + setools_dir.mkdir() + script = setools_dir / _script_name() + _write_fake_app_gen_toc(script) + artefact = _artefact_bin(tmp_path) + + atoc_path, address = sign_slot0( + str(setools_dir), str(script), str(artefact), "m55_he", "0x80010000" + ) + + assert address == _REAL_ATOC_ADDRESS + assert Path(atoc_path).samefile(setools_dir / "build" / "AppTocPackage.bin") + + copied = setools_dir / "build" / "images" / "m55_he.bin" + assert copied.read_bytes() == artefact.read_bytes() + + config = json.loads((setools_dir / "build" / "config" / "m55_he-slot0.json").read_text()) + assert config == slot0_config("m55_he", "m55_he.bin", "0x80010000", "M55_HE") + + +def test_sign_slot0_surfaces_a_nonzero_exit(tmp_path): + setools_dir = tmp_path / "setools" + setools_dir.mkdir() + script = setools_dir / _script_name() + _write_fake_app_gen_toc(script, exit_code=7, stderr_text="DEVICE mismatch") + artefact = _artefact_bin(tmp_path) + + with pytest.raises(FlashPlanError) as raised: + sign_slot0(str(setools_dir), str(script), str(artefact), "m55_he", "0x80010000") + msg = str(raised.value) + assert "app-gen-toc" in msg + assert "7" in msg + assert "DEVICE mismatch" in msg + + +def test_sign_slot0_raises_when_the_report_has_no_marker(tmp_path): + setools_dir = tmp_path / "setools" + setools_dir.mkdir() + script = setools_dir / _script_name() + _write_fake_app_gen_toc(script, map_line="nothing useful here") + artefact = _artefact_bin(tmp_path) + + with pytest.raises(FlashPlanError) as raised: + sign_slot0(str(setools_dir), str(script), str(artefact), "m55_he", "0x80010000") + assert "APP Package Start Address" in str(raised.value) + + +def test_sign_slot0_raises_when_the_blob_was_not_produced(tmp_path): + setools_dir = tmp_path / "setools" + setools_dir.mkdir() + script = setools_dir / _script_name() + _write_fake_app_gen_toc(script, write_blob=False) + artefact = _artefact_bin(tmp_path) + + with pytest.raises(FlashPlanError) as raised: + sign_slot0(str(setools_dir), str(script), str(artefact), "m55_he", "0x80010000") + assert "AppTocPackage.bin" in str(raised.value) + + +def test_sign_slot0_does_not_report_a_stale_atoc_from_a_soft_failing_respawn(tmp_path): + """tan-cli#365 (BLOCKER, hardware-destructive) / tan-cli#373 (BLOCKER + regression in #365's own fix). `build/app-package-map.txt` and + `build/AppTocPackage.bin` are FIXED, SETOOLS-wide paths (not + per-`entry_id`) that a PREVIOUS run may have already left well-formed -- + parsing/`isfile`-checking them after THIS spawn proves nothing about THIS + spawn unless a soft failure (app-gen-toc exits 0 without actually + writing) can be told apart from a real one. Seed both with a + stale-but-well-formed report/blob, then drive a fake `app-gen-toc` that + exits 0 WITHOUT touching either: `sign_slot0` must refuse rather than + silently hand back the stale pair -- `plan_alif_mram_jlink` would burn it + into on-die MRAM alongside a fresh app image, and recovery from that is + re-provisioning over SE-UART. + + **#373: the map must survive the refusal untouched.** #365's own first + fix told the soft failure apart by DELETING the map first -- which meant + a soft-failing re-sign destroyed every PRIOR entry too (a real defect, + not just this stale one): a second Flow D entry pointing its own + `flash_args.atoc_map` at this same file would lose its true address the + moment this deletion ran. This is the positive proof of the fix: the + seeded stale line is still readable, byte for byte, AFTER the raise -- + `sign_slot0` refuses without deleting anything.""" + setools_dir = tmp_path / "setools" + (setools_dir / "build").mkdir(parents=True) + stale_report = f"APP Package Start Address: {_REAL_ATOC_ADDRESS}\n" + (setools_dir / "build" / "app-package-map.txt").write_text(stale_report, encoding="utf-8") + (setools_dir / "build" / "AppTocPackage.bin").write_bytes(b"stale-atoc-bytes") + + script = setools_dir / _script_name() + _write_noop_app_gen_toc(script) + artefact = _artefact_bin(tmp_path) + + with pytest.raises(FlashPlanError) as raised: + sign_slot0(str(setools_dir), str(script), str(artefact), "m55_he", "0x80010000") + assert "was not updated" in str(raised.value) + + # The stale map is NEVER deleted -- it is APPEND-mode, the accumulated + # sign record for the whole install, not per-run scratch (tan-cli#373). + assert (setools_dir / "build" / "app-package-map.txt").read_text( + encoding="utf-8" + ) == stale_report + + +def test_sign_slot0_never_deletes_the_append_mode_map_on_a_real_sign(tmp_path): + """tan-cli#373 (BLOCKER regression in #365's own fix): the positive half + of the guard above. `app-package-map.txt` is APPEND-mode -- a real + `app-gen-toc` run adds a new block, it never truncates the file (per + `flash_plan.parse_atoc_start_address`'s own docstring, citing the + measured bench scripts) -- so a prior entry (this entry's own earlier + run, another entry's, or a hand-run done outside tan) must survive a + fresh, SUCCESSFUL sign untouched, and `sign_slot0` must return the LAST + (this run's) address, not the first.""" + setools_dir = tmp_path / "setools" + (setools_dir / "build").mkdir(parents=True) + stale_line = "APP Package Start Address: 0x8000F000" + (setools_dir / "build" / "app-package-map.txt").write_text( + stale_line + "\n", encoding="utf-8" + ) + + script = setools_dir / _script_name() + _write_fake_app_gen_toc(script, append=True) + artefact = _artefact_bin(tmp_path) + + _atoc_path, address = sign_slot0( + str(setools_dir), str(script), str(artefact), "m55_he", "0x80010000" + ) + + assert address == _REAL_ATOC_ADDRESS + map_text = (setools_dir / "build" / "app-package-map.txt").read_text(encoding="utf-8") + assert stale_line in map_text, "a prior entry is not per-run scratch -- it must survive" + assert map_text.count("APP Package Start Address:") == 2 + + +def test_sign_slot0_guards_the_entry_id_charset(tmp_path): + """`entry_id` becomes a filename AND a JSON key -- the same + `validate_identifier` charset guard `flash_plan.py` uses everywhere else + a manifest value is interpolated into a spawned tool's inputs.""" + setools_dir = tmp_path / "setools" + setools_dir.mkdir() + script = setools_dir / _script_name() + _write_fake_app_gen_toc(script) + artefact = _artefact_bin(tmp_path) + + with pytest.raises(FlashPlanError): + sign_slot0(str(setools_dir), str(script), str(artefact), "a;b", "0x80010000") + + +# ── the full resolve -> find -> sign path, via find_app_gen_toc itself ────── + + +def test_find_app_gen_toc_then_sign_slot0_end_to_end(tmp_path, monkeypatch): + """Proves `find_app_gen_toc`'s OWN lookup (not just `sign_slot0` given an + already-known path) chains into a real sign. `APP_GEN_TOC` is + monkeypatched to the platform-spawnable name ONLY on Windows (see the + module docstring); `find_app_gen_toc`'s lookup logic itself is + untouched, real, and runs unmodified either way.""" + setools_dir = tmp_path / "setools" + setools_dir.mkdir() + name = _script_name() + if name != setools_module.APP_GEN_TOC: + monkeypatch.setattr(setools_module, "APP_GEN_TOC", name) + script_path = setools_dir / name + _write_fake_app_gen_toc(script_path) + artefact = _artefact_bin(tmp_path) + + found = find_app_gen_toc(str(setools_dir)) + assert found == str(script_path) + + atoc_path, address = sign_slot0( + str(setools_dir), found, str(artefact), "m55_he", "0x80010000" + ) + assert address == _REAL_ATOC_ADDRESS + assert os.path.isfile(atoc_path) diff --git a/python/tests/core/test_venv.py b/python/tests/core/test_venv.py index 477e6779..706e8f74 100644 --- a/python/tests/core/test_venv.py +++ b/python/tests/core/test_venv.py @@ -10,6 +10,7 @@ import os from pathlib import Path +from tan.core import venv as venv_module from tan.core.venv import ( find_workspace_venv, tool_in_venv, @@ -164,7 +165,14 @@ def test_tool_in_venv_appends_exe_only_on_windows_and_only_once(tmp_path): def test_west_program_falls_back_to_the_bare_path_name(tmp_path, monkeypatch): + """Pinned like `test_build_planner_python.py:74-84` pins the identical + `find_workspace_venv` walk on `_planner_python`'s side: `venv_bin_dir` + (which `west_program` calls) walks from `empty` all the way to the + filesystem root looking for a west-capable `.venv` -- a developer machine + with one anywhere above the OS temp dir would red this test for reasons + unrelated to the code under test.""" monkeypatch.delenv("ZEPHYR_BASE", raising=False) + monkeypatch.setattr(venv_module, "find_workspace_venv", lambda *_args: None) empty = tmp_path / "no-venv-here" empty.mkdir() assert west_program(str(empty), None) == "west" diff --git a/python/tests/core/test_zephyr_env.py b/python/tests/core/test_zephyr_env.py new file mode 100644 index 00000000..8e26b450 --- /dev/null +++ b/python/tests/core/test_zephyr_env.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#308: `zephyr_env_overrides` -- port of `crates/tan-cli/src/ +commands/build/execute/env.rs`'s `zephyr_env_overrides`, confirmed against +that module's own compiled unit tests (`cargo test -p alp-tan-cli --bin tan +commands::build::execute::env::`) since the oracle binary's `--plan-from` +implies `--plan` and so cannot dispatch a synthetic plan end to end without a +real `alp_orchestrate.py` emission -- see this module's own docstring.""" +from pathlib import Path + +from tan.core.plan_exec import apply_env_append +from tan.core.zephyr_env import zephyr_env_overrides + +# `zephyr_env_overrides` takes real `Path`s and emits `str(path)`, so on +# Windows `Path("/sdk")` renders `\sdk`, not `/sdk`. Comparing against a +# POSIX literal made all five of these fail on `test (windows-latest)` -- +# a test-only defect (production feeds real resolved paths, which render +# correctly on both platforms), but a red REQUIRED gate all the same. Derive +# the expectations through the same `str(Path(...))` the code under test +# uses so each assertion means what it says on either platform. +SDK = str(Path("/sdk")) +WS_ZEPHYR = str(Path("/ws/zephyr")) +#: An inherited env var is a raw string the user exported -- NOT round-tripped +#: through `Path` by the code under test, so it stays literal on both platforms. +MY_MODULE = "/home/u/my-module" + + +def no_inherited(_key: str) -> str | None: + return None + + +def test_fills_base_and_modules_when_absent(): + got = zephyr_env_overrides( + Path("/ws/zephyr"), Path("/sdk"), slice_env={"ALP_SDK_ROOT": "/sdk"}, + env_append_path={}, inherited=no_inherited, + ) + assert got == [("ZEPHYR_BASE", WS_ZEPHYR), ("EXTRA_ZEPHYR_MODULES", SDK)] + + +def test_respects_plan_pinned_keys(): + """The plan already pins both -- nothing is overridden.""" + got = zephyr_env_overrides( + Path("/ws/zephyr"), Path("/sdk"), + slice_env={"ZEPHYR_BASE": "/pinned", "EXTRA_ZEPHYR_MODULES": "/pinned-mod"}, + env_append_path={}, inherited=no_inherited, + ) + assert got == [] + + +def test_skips_extra_modules_when_plan_appends_it(): + """Plan wins / CLI fills gaps: the plan carries EXTRA_ZEPHYR_MODULES in + envAppendPath, so the CLI must NOT hand-derive it -- but ZEPHYR_BASE + (which the plan never carries) is still filled in.""" + got = zephyr_env_overrides( + Path("/ws/zephyr"), Path("/sdk"), slice_env={}, + env_append_path={"EXTRA_ZEPHYR_MODULES": ["/plan/sdk"]}, inherited=no_inherited, + ) + assert got == [("ZEPHYR_BASE", WS_ZEPHYR)] + + +def test_empty_when_nothing_resolved(): + assert zephyr_env_overrides(None, None, {}, {}, no_inherited) == [] + + +def test_extends_rather_than_replaces_an_inherited_extra_zephyr_modules(): + """Regression: an earlier shape of this gap-filler returned the bare SDK + root, and the caller's gap-filler merge OVERWRITES the var outright -- so + a developer's own `export EXTRA_ZEPHYR_MODULES=/home/u/my-module` vanished + from the build on any plan that didn't itself pin the key.""" + got = zephyr_env_overrides( + None, Path("/sdk"), slice_env={}, env_append_path={}, + inherited=lambda k: MY_MODULE if k == "EXTRA_ZEPHYR_MODULES" else None, + ) + # EXTRA_ZEPHYR_MODULES is a Zephyr CMake list: joined with ';' on EVERY + # platform (plan_exec.sep_for_key), not os.pathsep. + assert got == [("EXTRA_ZEPHYR_MODULES", f"{MY_MODULE};{SDK}")] + + +def test_inherited_value_already_containing_the_sdk_root_is_not_duplicated(): + """`apply_env_append`'s own de-dup applies here too -- confirmed by + reusing the exact same helper the plan-driven envAppendPath path uses, + not a re-implementation.""" + got = zephyr_env_overrides( + None, Path("/sdk"), slice_env={}, env_append_path={}, + inherited=lambda k: SDK if k == "EXTRA_ZEPHYR_MODULES" else None, + ) + assert got == [("EXTRA_ZEPHYR_MODULES", SDK)] + + +def test_matches_apply_env_append_directly_for_the_fallback_case(): + """The gap-filler's EXTRA_ZEPHYR_MODULES value must be reachable via the + SAME machinery `assemble_slice_env`'s own envAppendPath handling uses -- + not a parallel join implementation that could drift from it.""" + base = [("EXTRA_ZEPHYR_MODULES", MY_MODULE)] + apply_env_append(base, {"EXTRA_ZEPHYR_MODULES": [SDK]}) + got = zephyr_env_overrides( + None, Path("/sdk"), slice_env={}, env_append_path={}, + inherited=lambda k: MY_MODULE if k == "EXTRA_ZEPHYR_MODULES" else None, + ) + assert got == [base[0]] diff --git a/python/tests/gates/test_every_issue_code_is_registered.py b/python/tests/gates/test_every_issue_code_is_registered.py new file mode 100644 index 00000000..776ecf91 --- /dev/null +++ b/python/tests/gates/test_every_issue_code_is_registered.py @@ -0,0 +1,1548 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#224: the Python emit-site gate the Rust one cannot stand in for. + +`crates/tan-cli/tests/contract.rs` carries a PAIR of tests -- +`every_emitted_issue_code_is_registered` (tan-cli#219: walks every literal +`code: "family.name"` in `crates/` and asserts it is in +`contract/issue-codes.json` at some status) and +`every_prefixed_issue_code_is_registered` (tan-cli#224 itself: a DECLARED +list of `PREFIXING_SITES`, because a code assembled as +`format!("bootstrap.{code}")` from a bare suffix never appears as one whole +literal and the first test structurally cannot see it). Both landed in +commit 78d8308. + +`crates/` ships to NOBODY -- the release assets are PyInstaller freezes of +`python/tan` (tan-cli#271) -- so on the surface that actually reaches a +customer, NEITHER direction of this gate existed until this file. The +prefixing shape is not hypothetical on the Python side either: +`bootstrap_cmd.py`, `debug_config_cmd.py`, `doctor_cmd.py`, `sdk_cmd.py` and +`validate_cmd.py` all build a code the same way, and `deferred_cmd.py`'s +`cli.command-deferred` sat completely unregistered (assigned to a module +constant, never a whole literal at its `Issue(...)` call site) until the +audit this file's first run performed. + +WHAT THIS COVERS -- WIDER than the Rust pair's own two shapes, by design, and +that widening is itself the product of a remediation: an earlier version of +this file claimed parity with the Rust pair's two shapes while actually +implementing a narrower one, which left ~40% of tan's emitted codes ungated +(caught in review, not by the gate -- the exact fail-open requirement 4 below +exists to prevent, applied to this file about itself): + + 1. LITERAL sites -- `Issue("family.code", ...)`, `code="family.code"` + anywhere, and `Issue(NAME, ...)` where `NAME` is a module-level constant + assigned exactly that literal (`cli.command-deferred`'s actual shape). + 2. FULL-CODE-CARRYING CALL sites -- [`_FULL_CODE_CALLABLES`]: the port's + DOMINANT emit idiom is not `Issue("family.code", ...)` directly but a + per-command error TYPE (`BuildError`, `InitError`, `GenerateError`, ...) + or a small local wrapper (`_issue`, `fail_sdk`, `_refuse`, `_error`, ...) + constructed with the WHOLE literal code, later re-emitted through + `Issue(err.code, ...)` several frames away. Shape (1) structurally + cannot see the literal, because it never appears as `Issue(...)`'s own + argument -- it appears at the CONSTRUCTOR call. Declared per `(file, + callable name)` -> the positional index of the code argument, every call + site scanned, exactly like shape (3) below scans a prefixing helper's + call sites; a non-literal argument there is either a declared forward + ([`_KNOWN_CODE_FORWARDS`], e.g. `except BuildError as err: ... + Issue(err.code, ...)`, whose literal is captured at `err`'s OWN + construction site) or reported UNRESOLVED, never silently dropped. + 3. PREFIXED / FAMILY sites -- an f-string whose ENTIRE value is one literal + segment ending or starting with `.` plus exactly one substitution, in + EITHER order: `f"bootstrap.{code}"` (fixed prefix, substituted suffix) + or `f"{subcommand}.failed"` (substituted family, fixed suffix -- + `west_forward_cmd.py`'s mirrored shape). Auto-DISCOVERED across the + whole `tan/` tree (unlike the Rust list, which is hand-declared per + file) and then RESOLVED by scanning every call site of the one helper + function/constructor the f-string's substitution comes from, mirroring + `PREFIXING_SITES`'s "declared opener, scanned call sites, pinned count" + shape one level more automatically. + +Auto-discovery is the deliberate improvement over the Rust design for shape +(3): the Rust gate can only see a prefixing helper someone already added a +row for, so a FOURTH helper appearing elsewhere in `crates/` would escape +both of its own tests silently. Here, [`_prefix_templates`] finds every +"one fixed literal segment + one substitution" f-string in the tree by its +AST SHAPE, not by a hand-maintained file list, and +[`test_every_prefix_template_is_classified`] fails the moment one appears +that nothing below has classified -- so a fifth helper cannot hide the way a +fourth Rust one could. Shape (2) is declared rather than auto-discovered +(a bare `SomeClass("family.code", ...)` call has no AST feature that +distinguishes "this constructs a code-carrying error" from "this constructs +an unrelated value" the way a `f"prefix.{x}"` shape does), so it carries the +same non-vacuity discipline shape (3) does at the registration/test level +instead: [`test_every_emitted_issue_code_is_registered`]'s own count +(`len(literal) > 30`) would drop sharply if a `_FULL_CODE_CALLABLES` entry +silently stopped matching, the same tripwire `expected_calls` gives shape (3). + +Classifying what auto-discovery FINDS still takes a human, in one of three +declared buckets, the same non-heuristic discipline +`crates/tan-cli/tests/contract.rs`'s own `PREFIXING_SITES` and +`DECLARED_FORWARDERS` comments insist on ("a scan that guessed which +functions prefix would either miss a new one silently or invent codes from +unrelated calls"): + + * `_RESOLVABLE_HELPERS`, keyed by `(file, enclosing qualname)` -- the + dotted name of the function/method the f-string ITSELF sits inside + (`Log.take_issues`, `_refusal`, `validate.fail`), tracked by + [`_prefix_templates`] while it walks. DELIBERATELY NOT the f-string's + line number, which an earlier version of this table used and which broke + for real, not hypothetically: dev's fc88ca1 shifted `_refusal`'s own + template from :1522 to :1532 by adding lines earlier in the same file -- + changing nothing about `_refusal` itself -- and reddened this gate on an + unrelated, already-merged PR. A qualname is immune to that: it only + changes when the SITE itself is renamed, moved to a different function, + or rewritten, all of which genuinely warrant updating this table. It + still disambiguates every case a bare `(file, prefix, expr)` key could + not: `bootstrap_cmd.py` has TWO distinct templates that both substitute a + parameter literally named `code`, but one sits in `Log.take_issues` and + the other in `_refusal`, so the qualname alone tells them apart. The + substituted name is a plain parameter of the enclosing function/method + (`kind="prefix"`, the fixed literal is a PREFIX) or, mirrored, a + parameter of the function the f-string's SUBSTITUTED family comes from + while the SUFFIX is fixed (`kind="family"`, `west_forward_cmd.py`'s + `f"{subcommand}.failed"`) -- either way, every call site of that one + function is scanned, and a literal argument there IS the missing half. + Also covers a constructor whose call sites are scanned the same way even + though the substitution is not literally the enclosing function's own + parameter: `doctor_cmd.py`'s `f"doctor.{check.name}"` resolves by + scanning every `Check(...)` construction's `name` (48 call sites, all + literal -- MEASURED, not assumed, while closing tan-cli#224's own + review). A call passing something else (a `Name`, an `Attribute`, a + `Starred` unpack) is unresolved unless it also appears in + `_FORWARDER_SUFFIXES`. Two templates that share one qualname + (`west_forward_cmd.py`'s `_run_forward`, whose success and `OSError` arms + both build the identical `f"{subcommand}.failed"`) collapse to ONE + declared entry, not two -- they are the same resolvable site scanned + once, not two coincidentally-identical declarations to keep in sync by + hand. That collapse is exactly what a REVIEWER later showed was a hole, + not just an economy: because the key is `(file, qualname)`, a THIRD, + UNREGISTERED template landing inside an already-declared function is + indistinguishable from the two legitimate ones by key alone, and the old + code recorded only whether the key had been seen at all + (`seen_helper_keys: set`), not how many times. Concretely: adding a + second, shadowed-variable `f"bootstrap.{code}"` inside `_refusal` (a + comprehension `for code in (...)` shadows the parameter `_refusal` + already takes, so `ast.unparse` still reads the substituted expression as + plain `code`, and the declared literal/`kind` still match) passed every + existing assertion silently -- the only thing that caught it was + `EXPECTED_TEMPLATE_COUNT`'s own failure message, which says "bump the + count", not "this code is unregistered". Each `_RESOLVABLE_HELPERS` + entry therefore also declares `sites`: the exact number of + `_prefix_templates` matches expected at that key (`_run_forward`'s entry + reads `sites=2`, recording the "two, not one, not three" fact the old + code left implicit). [`_classify_and_resolve`] records every matched + lineno per key and calls [`_check_site_counts`], which fails, naming the + qualname, the declared vs. actual count, and every matched file:line, the + moment they diverge -- so the shadowed-comprehension shape above is now a + loud, specific failure ("_RESOLVABLE_HELPERS[...] declares sites=1 but + this run found 2") instead of a nudge to bump an unrelated scalar. + * `_FORWARDER_SUFFIXES`, keyed by `(file, exact substituted expression)` -- + the substituted expression is not a plain parameter (`refusal.code`, + `venv_refusal.code`, `result.outcome`, or a `*tuple` unpack) but its + value space was read from the real source and is small and closed (a + dataclass field fed by a handful of constructors, or an outcome derived + from two module constants). Carries the SAME `sites`-pinning discipline + the other two buckets do, for the SAME many-to-one reason, closing a + THIRD hole a reviewer found in this table specifically: the key is + `(file, expr)` -- no qualname, no call-site scope -- so a wholly + UNRELATED function whose own f-string happens to substitute an + identically-spelled expression collapses onto the same declared entry + and gets waved through by whatever suffix set that entry already + carries, without a single new call site ever being named. Measured: + adding `def _sneaky(refusal): return Issue(f"bootstrap.{refusal.code}", + "error", "smuggled")` to `bootstrap_cmd.py` and bumping + `EXPECTED_TEMPLATE_COUNT` 11 -> 12 gave "4 passed" -- every existing + assertion, silently. `sites` is the exact count of matches expected at + that key -- from [`_prefix_templates`]'s f-string scan for the three + plain-expression entries, or from [`_resolve_helper`]'s own + Starred-argument scan for the two `*tuple` entries -- asserted by the + SAME [`_check_site_counts`] the other two buckets now share. + * `_ACKNOWLEDGED_CEILINGS`, keyed by `(file, enclosing qualname)` -- the + same stable identity `_RESOLVABLE_HELPERS` keys on, for the same reason + (see its bullet above), mapping to `dict(reason=..., sites=...)` -- the + same `sites`-pinning discipline `_RESOLVABLE_HELPERS` carries, for the + same reason: an acknowledged ceiling is exactly as many-to-one a key as a + resolved helper, so a SECOND, unregistered template landing at an + already-acknowledged qualname deserves the same loud count mismatch, not + a silent "well, it's acknowledged" pass. `reason` is stated rather than + silently skipped, the same honesty the Rust gate's own "KNOWN CEILING" + paragraph practises. Held EMPTY today: `doctor_cmd.py`'s + `check.code or f"doctor.{check.name}"` ceiling this bucket used to carry + was resolved into `_RESOLVABLE_HELPERS` above once its actual cost was + measured (48 call sites, ALL literal, zero non-literal -- "a materially + bigger audit" was the ceiling's original claim, and it did not survive + contact with the real count). The bucket stays declared rather than + deleted so a FUTURE genuinely-out-of-scope template has somewhere + honest to go, per the same Rust "KNOWN CEILING" precedent -- an empty + table is not evidence no ceiling will ever be needed again. + +Shape (2) sites (`_FULL_CODE_CALLABLES`, see above) get the same declared, no +silent drop treatment through [`_KNOWN_CODE_FORWARDS`]: a code-position +argument (an `Issue(...)` first arg, any `code=` keyword, or a +`_FULL_CODE_CALLABLES` argument) that is neither a literal nor a resolved +module constant is either a declared forward -- its literal captured at the +ORIGIN this list points at, mirroring `crates/tan-cli/tests/contract.rs`'s +own `DECLARED_FORWARDERS` ("this list only says there is no literal HERE to +read, which is a fact about the call, not a licence to skip the code") -- or +reported UNRESOLVED by file:line, never silently dropped. + +Every one of these buckets is a place a REAL escape can still happen if +mis-declared -- which is exactly why +[`test_gate_rejects_a_deliberately_unregistered_code`] exists: tan-cli#275 is +the standing lesson that an assertion nobody has ever seen fail is not proven +to fire. Confirmed by hand while writing this file: with the fabricated code +below removed from the injected set the assertion goes red; restored, green +-- see that test's own body for the same check run programmatically. + +ACKNOWLEDGED CEILING -- a limit of static resolution, documented rather than +chased closed (a reviewer's second finding, tan-cli#224 review). Every bucket +above resolves a template's substituted expression by reading its TEXT +(`ast.unparse`) and matching that text against the enclosing callable's own +parameter name or a declared forward -- it does not, and structurally cannot +without reimplementing Python's own name resolution, check that the text it +read is actually BOUND the way the enclosing signature implies. A construct +that locally REBINDS the substituted name defeats that match while leaving +every surface signal this file checks unchanged. Measured, not hypothetical: +`_refusal`'s legitimate site is + + [Issue(f"bootstrap.{code}", "error", " ".join(lines))] + +where `code` is `_refusal`'s own parameter. Replacing it with + + [Issue(f"bootstrap.{code}", "error", " ".join(lines)) for code in ("sneaky-unregistered",)] + +keeps `sites=1` (still exactly one `f"bootstrap.{code}"` AST node), +keeps `kind`/literal/`expr` all matching `_RESOLVABLE_HELPERS[("tan/commands +/bootstrap_cmd.py", "_refusal")]` (`ast.unparse` reads the substituted name +as the bare identifier `code` either way -- it has no notion of WHICH `code` +a comprehension's own scope binds, only that the token spells the same), +keeps `EXPECTED_TEMPLATE_COUNT` at 11 -- and the gate stays green while +`bootstrap.sneaky-unregistered` is emitted at runtime, unregistered. + +WHAT THIS GATE DOES CATCH: every literal code, every `_FULL_CODE_CALLABLES` +site, and every prefix/family template whose substituted expression is +textually the identifier the matching declared spec says it is -- which is +every real site in this tree today (measured while writing this file: zero +unclassified, zero unresolved). WHAT IT STRUCTURALLY CANNOT: tell "this +occurrence of `code` is the enclosing function's OWN parameter" apart from +"this occurrence of `code` is a comprehension/`with`/`except`/nested-`def` +target that merely happens to share that spelling" -- that is a lexical- +scoping question, not an AST-shape one, and answering it for real means +building the same name-resolution pass CPython's own compiler does. Do NOT +add a heuristic that tries to detect local rebinding of a substituted name +-- shadowing a name is common, legitimate Python (this file's own `_scan`/ +`_walk` helpers do it), so a detector for it would either miss a subtler +shadow just as easily or flag ordinary, harmless code as suspect; either +way it is an arms race against a static-analysis limit, not a fix for one. + +THE ACTUAL BACKSTOP: this gate is a completeness REMINDER at review time, +not the last line of defence against an unregistered code reaching a +customer. The authority for what a consumer may rely on is +`contract/issue-codes.json` itself, and the release workflow's own "Bundle +the envelope contract" step (`.github/workflows/release.yml`) builds the +published `envelope-contract.json` directly FROM that registry file -- by +reading it, never by re-scanning `python/tan/` source -- so a code that +escapes this static scan is exactly as absent from the published contract as +a code nobody ever tried to register in the first place; this scan's ceiling +does not create a NEW way for that to happen, it just fails to add coverage +for one narrow shape of it. And on the wire, `alp-sdk-vscode`'s own `===` +match against a `frozen` code FAILS OPEN on anything it does not recognise +(`test_frozen_issue_codes.py`'s own docstring: "an unrecognised code is +indistinguishable from 'no problem' on the consumer side") -- so the +practical cost of this ceiling is a real problem going unsurfaced to a user +who hit it, not a crash, and the actual defence against that is a human +registering every new code in the one place (`contract/issue-codes.json`) +this gate, `test_frozen_issue_codes.py`, and the release step all read from. +""" + +from __future__ import annotations + +import ast +import json +import pathlib + +#: `contract/` lives at the repo root, one level above `python/` -- the same +#: resolution `test_frozen_issue_codes.py` uses, reused rather than +#: reinvented so there is exactly one place that path is computed. +REGISTRY = pathlib.Path(__file__).resolve().parents[3] / "contract" / "issue-codes.json" +TAN = pathlib.Path(__file__).resolve().parents[2] / "tan" + + +def _registered_codes() -> set[str]: + data = json.loads(REGISTRY.read_text(encoding="utf-8")) + codes = {e["code"] for e in data["issueCodes"]} + # Non-vacuity: an empty (or unreadable-as-expected) registry would make + # every assertion below pass by finding nothing to fail against -- + # exactly the tan-cli#275 shape this whole file exists to avoid. + assert codes, f"{REGISTRY} has no issue codes -- this gate would be vacuous" + return codes + + +def _is_code_literal(s: str) -> bool: + """Shaped like a whole `family.name` issue code: at least one dot, + otherwise lowercase/digits/dash/dot only. Deliberately narrow, the same + reason `crates/tan-cli/tests/contract.rs::emitted_code_literals` is + narrow -- so an unrelated `code="UTF-8"`-shaped kwarg or a prose string + can never be mistaken for a real code.""" + return bool(s) and "." in s and all(c.islower() or c.isdigit() or c in "-." for c in s) + + +def _is_code_suffix(s: str) -> bool: + """Shaped like the bare SUFFIX a prefixing helper takes: no dot at all + (a dot there would mean the caller already passed a whole code, which is + a literal-emit site, not a prefixed one). Two shapes accepted: the + kebab-case convention every HAND-WRITTEN suffix in this tree uses + (`board-yaml-missing`), or a bare camelCase identifier for the one + MECHANICALLY-resolved exception -- `doctor_cmd.py`'s `Check(...)` `name`s + mirror the Rust oracle's own `doctor.` convention verbatim + (`checks_to_issues`'s own docstring says so), so `boardYaml`/`zephyrSdk`/ + ... are real suffixes this scan must accept, not reject as malformed.""" + if not s or "." in s: + return False + if all(c.islower() or c.isdigit() or c == "-" for c in s): + return True + return s[0].islower() and all(c.isalnum() for c in s) + + +def _is_family_prefix(s: str) -> bool: + """Shaped like `"bootstrap."` -- lowercase/dash, exactly one trailing + dot and no other. Guards [`_prefix_templates`] against an unrelated + f-string (a path, a URL) that happens to end a literal segment in `.`.""" + return s.endswith(".") and s.count(".") == 1 and len(s) > 1 and all(c.islower() or c == "-" for c in s[:-1]) + + +def _is_family_suffix(s: str) -> bool: + """Shaped like `".failed"` -- the MIRROR of [`_is_family_prefix`]: one + leading dot, then lowercase/dash, no other dot. Guards the "substituted + FAMILY, fixed SUFFIX" template shape (`f"{subcommand}.failed"`, + `west_forward_cmd.py`) against an unrelated f-string ending a literal + segment in `.something` that is not a code suffix.""" + return s.startswith(".") and s.count(".") == 1 and len(s) > 1 and all(c.islower() or c == "-" for c in s[1:]) + + +def _parse(path: pathlib.Path) -> ast.Module: + return ast.parse(path.read_text(encoding="utf-8", errors="replace"), filename=str(path)) + + +def _rel(path: pathlib.Path) -> str: + """`tan/commands/foo.py`, the same spelling used throughout this file's + declared tables -- so a table entry can be found by grepping this file + for the exact string that would appear in a failure message. + + Falls back to `path` unchanged for a path outside `tan/`: a pytest + `tmp_path` self-test scratch file (`test_prefix_template_scan_finds_a_fresh_synthetic_site`) + is scanned by `_literal_codes_in_file`, which now calls `_rel` to key its + `_FULL_CODE_CALLABLES`/`_KNOWN_CODE_FORWARDS` lookups -- no declared table + entry can ever match a path outside `tan/`, so the exact spelling does not + matter there, but crashing on `relative_to` does.""" + try: + return str(path.relative_to(TAN.parent)).replace("\\", "/") + except ValueError: + return str(path).replace("\\", "/") + + +def _module_string_constants(tree: ast.Module) -> dict[str, str]: + """Module-level `NAME = "literal.with.a.dot"` assignments -- the + `cli.command-deferred` shape (`DEFERRED_ISSUE_CODE` in + `deferred_cmd.py`), where the whole code is named once and referenced by + identifier at the `Issue(...)` call site rather than spelled inline. + Deliberately shallow: only a direct top-level `Assign` to a `Name` + counts, so a value reassigned or computed elsewhere is correctly left + unresolved rather than guessed at.""" + consts: dict[str, str] = {} + for node in tree.body: + if ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + and _is_code_literal(node.value.value) + ): + consts[node.targets[0].id] = node.value.value + return consts + + +#: `(file, callable name)` -> the positional index of the argument that +#: carries an ALREADY-WHOLE issue code, for constructors/local helpers whose +#: declared purpose is exactly that (not a bare suffix needing a prefix -- +#: `_RESOLVABLE_HELPERS` below covers that shape). See the module docstring's +#: shape (2): the port's dominant emit idiom is a per-command error TYPE +#: (`BuildError`, `InitError`, ...) or a small local wrapper (`_issue`, +#: `fail_sdk`, `_refuse`, `_error`, `_error_outcome`, `_Notice`) constructed +#: with the whole code, later re-emitted through `Issue(err.code, ...)` -- +#: several call frames from the eventual `Issue(...)` shape (1) alone can see. +#: Every entry here was read from source (a `grep` for the class/function +#: name, then its `__init__`/signature, then every call site), the same +#: discipline `_RESOLVABLE_HELPERS`/`_FORWARDER_SUFFIXES` already apply. +_FULL_CODE_CALLABLES: dict[tuple[str, str], int] = { + ("tan/core/build_plan.py", "PlanParseError"): 0, + ("tan/commands/monitor_cmd.py", "MonitorError"): 0, + ("tan/commands/explain_cmd.py", "ExplainError"): 0, + ("tan/commands/generate_cmd.py", "GenerateError"): 0, + ("tan/commands/build/token_substitution.py", "TokenSubstitutionError"): 0, + ("tan/commands/build_cmd.py", "BuildError"): 0, + ("tan/commands/build_cmd.py", "_refuse"): 0, + ("tan/commands/build/materialise.py", "MaterialiseError"): 0, + ("tan/commands/model_cmd.py", "ModelError"): 0, + ("tan/commands/kconfig_cmd.py", "_CoreResolutionError"): 0, + ("tan/commands/init_cmd.py", "InitError"): 0, + ("tan/commands/renode_cmd.py", "_issue"): 0, + ("tan/commands/renode_cmd.py", "fail"): 0, + ("tan/commands/renode_cmd.py", "fail_sdk"): 0, + ("tan/commands/flash_cmd.py", "_error"): 1, + ("tan/commands/image_cmd.py", "_error_outcome"): 3, + ("tan/commands/image_cmd.py", "_Notice"): 0, + ("tan/commands/size_cmd.py", "_error_outcome"): 2, + ("tan/commands/scaffold_cmd.py", "ScaffoldError"): 0, +} + +#: `(file, exact unparsed expression)` -> declared as a KNOWN forward, never +#: a silent skip -- mirrors `crates/tan-cli/tests/contract.rs`'s own +#: `DECLARED_FORWARDERS` ("this list only says there is no literal HERE to +#: read, which is a fact about the call, not a licence to skip the code"). +#: Applies at every code-position argument this file inspects: `Issue(...)`'s +#: first arg, any `code=` keyword, and every `_FULL_CODE_CALLABLES` argument. +#: Every entry's literal IS captured elsewhere in this same scan: an +#: `except Error as err:` block re-emitting `err.code` (`Error` is +#: itself in `_FULL_CODE_CALLABLES`, so its OWN construction sites carry the +#: literal), or a module constant imported from another file (`deferred_cmd +#: .py`'s `DEFERRED_ISSUE_CODE`, resolved by `_module_string_constants` only +#: at ITS OWN definition site -- deliberately shallow, per that function's own +#: docstring -- so the cross-module import here needs its own declared entry). +_KNOWN_CODE_FORWARDS: frozenset[tuple[str, str]] = frozenset( + { + ("tan/commands/build_cmd.py", "err.code"), # BuildError <- PlanParseError/TokenSubstitutionError + ("tan/commands/build_cmd.py", "DEFERRED_ISSUE_CODE"), # imported from deferred_cmd.py + ("tan/commands/build_cmd.py", "code"), # `Issue(code, ...)` inside `_refuse`'s OWN body, + # forwarding ITS OWN `code` parameter -- `_refuse` is itself in + # `_FULL_CODE_CALLABLES`, so its call sites carry the literal. + ("tan/commands/monitor_cmd.py", "err.code"), # <- MonitorError + ("tan/commands/init_cmd.py", "err.code"), # <- InitError + ("tan/commands/model_cmd.py", "err.code"), # <- ModelError + ("tan/commands/explain_cmd.py", "err.code"), # <- ExplainError + ("tan/commands/run_cmd.py", "err.code"), # <- BuildError (run retags build's own refusal) + ("tan/commands/generate_cmd.py", "err.code"), # <- GenerateError + ("tan/commands/kconfig_cmd.py", "err.code"), # <- _CoreResolutionError (via `code=err.code`) + ("tan/commands/kconfig_cmd.py", "code"), # `Issue(code, ...)` inside `_fail`'s OWN body -- + # `_fail`'s literal `code=` call sites are already caught by the plain + # `code=` keyword scan above; this is only its internal forward. + ("tan/commands/flash_cmd.py", "code"), # `Issue(code, ...)` inside `_error`'s OWN body -- + # `_error` is itself in `_FULL_CODE_CALLABLES`. + ("tan/commands/image_cmd.py", "n.code"), # <- _Notice, one per bundle-assembly gap + ("tan/commands/image_cmd.py", "code"), # `Issue(code, ...)` inside `_error_outcome`'s OWN + # body -- `_error_outcome` is itself in `_FULL_CODE_CALLABLES`. + ("tan/commands/size_cmd.py", "code"), # same shape, `size_cmd.py`'s own `_error_outcome`. + ("tan/commands/renode_cmd.py", "code"), # `_issue(code, ...)` inside `fail`/`fail_sdk`'s OWN + # bodies, forwarding THEIR OWN `code` parameter -- `fail`/`fail_sdk` + # are themselves in `_FULL_CODE_CALLABLES`, so their call sites carry it. + ("tan/commands/scaffold_cmd.py", "err.code"), # <- ScaffoldError + ("tan/commands/diff_cmd.py", "failure.code"), # <- ParseFailure. NOT a whole code (it is a + # bare suffix, e.g. "schema-violation" -- ParseFailure is deliberately + # NOT in _FULL_CODE_CALLABLES, since _is_code_literal requires a dot + # and a bare suffix has none). This entry only silences the generic + # `code=` keyword scan below; the actual literal is captured by the + # PREFIX-TEMPLATE mechanism instead (`_FORWARDER_SUFFIXES[("tan/commands + # /diff_cmd.py", "failure.code")]`, consulted from inside + # `_resolve_helper` while scanning `_emit_failure(...)`'s own call + # sites) -- "captured elsewhere in this same scan" still holds, just + # in the sibling scan this file also runs. + } +) + + +def _resolve_code_value( + rel: str, value: ast.expr | None, consts: dict[str, str] +) -> tuple[str, str | None]: + """Classify one code-position argument. Returns `(status, payload)`: + + * `("literal", code)` -- `value` is a resolvable code literal (a + `Constant` shaped like a whole `family.name` code, or a known + module-string-constant `Name`). + * `("ignored", None)` -- `value` is owned by a DIFFERENT, already-asserted + mechanism, so reporting it here would duplicate that mechanism's own + check rather than add coverage: a `Constant` string that is NOT + code-shaped (no dot -- a bare SUFFIX literal, e.g. `_fail(code="not- + ported", ...)`, which `_RESOLVABLE_HELPERS`/`_resolve_helper` scans + these exact call sites for separately), or an `ast.JoinedStr`/ + `ast.BoolOp` (an f-string or `x or f"..."` -- the family/prefix- + template shape `_prefix_templates`/`_classify_and_resolve` owns, with + its own unresolved/unclassified assertions). + * `("forward", None)` -- `(rel, unparsed value)` is declared in + `_KNOWN_CODE_FORWARDS` -- deliberately skipped, the code is captured at + its own origin. + * `("unresolved", unparsed value)` -- none of the above: a real escape, + reported by file:line, never silently dropped. + """ + if isinstance(value, ast.Constant) and isinstance(value.value, str): + if _is_code_literal(value.value): + return "literal", value.value + return "ignored", None + if isinstance(value, ast.Name) and value.id in consts: + return "literal", consts[value.id] + if isinstance(value, (ast.JoinedStr, ast.BoolOp)): + return "ignored", None + expr = ast.unparse(value) if value is not None else "" + if (rel, expr) in _KNOWN_CODE_FORWARDS: + return "forward", None + return "unresolved", expr + + +def _literal_codes_in_file(path: pathlib.Path) -> tuple[set[str], list[str]]: + """Every LITERAL whole-code emit site in one file, plus every + code-position argument that could NOT be resolved (never silently + dropped -- tan-cli#224's own review finding). Three shapes: + `Issue("family.code", ...)` (first positional arg, resolving a module + constant when the arg is a bare `Name`), `code="family.code"` (a keyword + named `code`, wherever it appears -- deliberately not scoped to any one + callee, the same breadth `contract.rs`'s `code: "..."` text scan has), + and a call to any `_FULL_CODE_CALLABLES` entry declared for THIS file.""" + rel = _rel(path) + tree = _parse(path) + consts = _module_string_constants(tree) + callables_here = {name: idx for (f, name), idx in _FULL_CODE_CALLABLES.items() if f == rel} + found: set[str] = set() + unresolved: list[str] = [] + + def _record(lineno: int, site: str, value: ast.expr | None) -> None: + status, payload = _resolve_code_value(rel, value, consts) + if status == "literal": + assert payload is not None + found.add(payload) + elif status == "unresolved": + unresolved.append(f"{rel}:{lineno} -- {site} argument is not a resolvable code literal ({payload})") + # "forward" / "ignored": declared safe or owned elsewhere -- nothing to record. + + for node in ast.walk(tree): + if isinstance(node, ast.keyword) and node.arg == "code": + _record(node.lineno, "a `code=` keyword", node.value) + continue + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + if node.func.id == "Issue" and node.args: + _record(node.lineno, "`Issue(...)`'s first", node.args[0]) + continue + if node.func.id in callables_here: + idx = callables_here[node.func.id] + arg = node.args[idx] if len(node.args) > idx else None + _record(node.lineno, f"`{node.func.id}(...)`'s (declared in _FULL_CODE_CALLABLES)", arg) + continue + return found, unresolved + + +def _prefix_templates(path: pathlib.Path) -> list[tuple[int, str, str, str, str]]: + """Every f-string used AT A CODE POSITION in `path` -- `Issue(...)`'s + first argument, or a `code=` keyword's value, the SAME two positions + [`_literal_codes_in_file`] inspects -- shaped EXACTLY `[one fixed literal + segment ending/starting with `.`, ONE substitution]`, in either order -- + e.g. `f"bootstrap.{code}"` (`kind="prefix"`) or `f"{subcommand}.failed"` + (`kind="family"`). Returns `(lineno, literal segment, unparsed + substituted expression, kind, enclosing qualname)`. + + The enclosing qualname (`Log.take_issues`, `_refusal`, `validate.fail`) + is the dotted name of the innermost function/method/class the f-string + sits inside, built while walking (the same idea Python's own + `__qualname__` encodes, minus the `` marker CPython inserts for a + nested function -- unneeded here, since nothing downstream reconstructs a + real `__qualname__`, it only looks a site up BY this identity). This is + what [`_RESOLVABLE_HELPERS`]/[`_ACKNOWLEDGED_CEILINGS`] key on INSTEAD of + `lineno`: `lineno` is still returned (and still belongs in every error + message, so a human can jump straight to the site), but it is not part of + any declared, hand-maintained key any more -- see those tables' own + comments for the concrete break (tan-cli#224, dev's fc88ca1) that made + keying on it a defect rather than a convenience. + + Scoping to code positions (rather than "any f-string in the file") is + load-bearing, not cosmetic: this tree has MANY unrelated f-strings + sharing the bare dot-suffix SHAPE -- `f"{sku}.yaml"`, `f"{tool}.exe"`, + `f"{field}.path"` -- that `_is_family_suffix` alone cannot distinguish + from a real code-assembling template (unlike `_is_family_prefix`'s + multi-char prefixes, which happen not to collide with anything else in + this tree today). `_is_family_prefix`/`_is_family_suffix` narrow the + SHAPE; scoping to code positions narrows WHERE that shape is even + looked for -- caught by measurement while closing tan-cli#224's own + review (28 false positives from an unscoped scan, none of them a real + issue-code template). + + Walks the WHOLE subtree of each code-position expression (not just its + top level), so a JoinedStr nested one level in -- `doctor_cmd.py`'s + `check.code or f"doctor.{check.name}"`, a `BoolOp` -- is still found. + """ + tree = _parse(path) + out: list[tuple[int, str, str, str, str]] = [] + stack: list[str] = [] + + def _qualname() -> str: + return ".".join(stack) if stack else "" + + def _scan(value: ast.expr | None) -> None: + if value is None: + return + qualname = _qualname() + for node in ast.walk(value): + if not isinstance(node, ast.JoinedStr) or len(node.values) != 2: + continue + head, tail = node.values + if ( + isinstance(head, ast.FormattedValue) + and isinstance(tail, ast.Constant) + and isinstance(tail.value, str) + and _is_family_suffix(tail.value) + ): + out.append((node.lineno, tail.value, ast.unparse(head.value), "family", qualname)) + continue + if ( + isinstance(head, ast.Constant) + and isinstance(head.value, str) + and _is_family_prefix(head.value) + and isinstance(tail, ast.FormattedValue) + ): + out.append((node.lineno, head.value, ast.unparse(tail.value), "prefix", qualname)) + + def _walk(node: ast.AST) -> None: + # Tracks the enclosing def/class stack (for the qualname `_scan` + # reads) while still visiting EVERY node in the tree, the same + # completeness the old flat `ast.walk(tree)` had -- a nested + # function/class pushes its name, recurses into its own body, then + # pops, so a template two scopes deep still gets the full dotted + # name. + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + stack.append(node.name) + for child in ast.iter_child_nodes(node): + _walk(child) + stack.pop() + return + if isinstance(node, ast.keyword) and node.arg == "code": + _scan(node.value) + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "Issue" and node.args: + _scan(node.args[0]) + for child in ast.iter_child_nodes(node): + _walk(child) + + _walk(tree) + return out + + +# --------------------------------------------------------------------------- +# The declared classification of every prefix template this tree contains +# today (tan-cli#224). See the module docstring for what each bucket means +# and why a fourth is deliberately not offered. +# --------------------------------------------------------------------------- + +#: The exact number of `_prefix_templates` matches across the whole `tan/` +#: tree, TODAY. Pinned exactly, the same reason +#: `crates/tan-cli/tests/contract.rs`'s `PREFIXED_CODE_COUNT` and per-file +#: `expected_sites` are pinned rather than floored: a template silently +#: disappearing (a rename that stops covering an emit) is exactly as real a +#: defect as a new one silently appearing uncovered, and only an EXACT count +#: notices the first case. Deliberately kept as a scalar count, not replaced: +#: it is ALREADY immune to the line-shift defect this file's re-keying fixes +#: (tan-cli#224) -- it counts template OCCURRENCES across the tree, never a +#: line number, so an unrelated edit that merely moves existing sites cannot +#: change it. It still needs a hand bump on a genuine content change (a +#: template added or removed), which is the correct, narrow cost a drift +#: detector for "did the template COUNT change" should have -- the defect +#: fixed here was the SEPARATE `_RESOLVABLE_HELPERS`/`_ACKNOWLEDGED_CEILINGS` +#: keys pinning WHERE each one lives, not this total. +#: +#: KEPT alongside the per-key `sites` field ALL THREE declared tables now +#: carry (`_RESOLVABLE_HELPERS`/`_ACKNOWLEDGED_CEILINGS` from the first +#: review round, `_FORWARDER_SUFFIXES` from the second, tan-cli#224 review +#: remediation), deliberately not replaced by either: the two are NOT +#: redundant, because they cover different ground. `sites` only exists at +#: keys someone has DECLARED in one of the three tables -- of today's 11 +#: templates, all 11 now sit behind a `sites` pin, the last 3 +#: (`bootstrap_cmd.py`'s `refusal.code` forward, `doctor_cmd.py`'s +#: `venv_refusal.code` forward, `validate_cmd.py`'s `result.outcome` forward) +#: via `_FORWARDER_SUFFIXES`'s OWN `sites` field once a reviewer showed the +#: identical many-to-one collapse reaches a `(file, expr)` key exactly as +#: easily as a `(file, qualname)` one -- but a BRAND NEW expression or +#: qualname nobody has declared ANYWHERE still only shows up as a total +#: mismatch here, never as a per-key one (there is no key yet for it to +#: collapse onto). Conversely, `sites` catches something this scalar cannot +#: localize on its own: which SPECIFIC key absorbed an extra template, by +#: name, with every offending file:line -- this total only says "11 became +#: 12" and, on its own, invites exactly the "bump the number and move on" +#: response the reviewer's exploit relied on. One coarse, whole-tree tripwire +#: plus precise per-key tripwires is not the same overlap as two detectors +#: both hand-bumped for the SAME fact; dropping either narrows real coverage. +EXPECTED_TEMPLATE_COUNT = 14 + +#: `(file, enclosing qualname)` -> how to recover the missing half, for every +#: template resolvable by scanning one declared callable's call sites. +#: `enclosing qualname` is the dotted name of the function/method the +#: f-string ITSELF sits inside (`Log.take_issues`, `_refusal`, +#: `validate.fail`), tracked by [`_prefix_templates`] while it walks -- +#: DELIBERATELY NOT the f-string's line number, which an earlier version of +#: this table used and which broke for real (tan-cli#224): dev's fc88ca1 +#: shifted `_refusal`'s own template from :1522 to :1532 by adding lines +#: earlier in the same file, changing nothing about `_refusal` itself, and +#: reddened this gate on an unrelated, already-merged PR. A qualname is +#: immune to that -- it only changes if the SITE itself is renamed, moved to +#: a different function, or rewritten, all of which genuinely warrant +#: updating this table -- and it still disambiguates every case a bare +#: `(file, prefix, expr)` key could not: `bootstrap_cmd.py` has TWO distinct +#: templates that both substitute a parameter literally named `code`, but +#: one sits in `Log.take_issues` and the other in `_refusal`. `kind` +#: (default `"prefix"`) picks which half is fixed: `"prefix"` -- `prefix` is +#: the fixed literal, `expr`'s call-site argument is the SUFFIX +#: (`f"bootstrap.{code}"`, `bootstrap.` + scanned code); `"family"` -- +#: `suffix` is the fixed literal, `expr`'s call-site argument is the FAMILY +#: (`f"{subcommand}.failed"`, scanned subcommand + `.failed`). Either way the +#: named callable's call sites are scanned the same way (`name`/`attr`, +#: `arg_index`/`arg_keyword`) -- a constructor whose call sites carry the +#: missing half works exactly like a helper whose OWN parameter does +#: (`doctor_cmd.py`'s `Check(name=...)` below is a constructor, not the +#: f-string's enclosing function; `_resolve_helper` does not care which). +#: Two templates that share one qualname (`west_forward_cmd.py`'s +#: `_run_forward`, whose success and `OSError` arms both build the identical +#: `f"{subcommand}.failed"`) collapse to ONE entry below, not two -- they are +#: the same resolvable site scanned once, never two coincidentally-identical +#: declarations to keep in sync by hand. +#: Every entry's `sites` field is the exact number of `_prefix_templates` +#: MATCHES (f-string occurrences) expected at that `(file, qualname)` key -- +#: a SEPARATE axis from `expected_calls` (the number of calls to the scanned +#: helper/constructor itself, e.g. `Log.warn(...)` call sites). This is the +#: closing of the hole a reviewer found in the qualname re-keying (tan-cli +#: #224): because the key is `(file, qualname)`, not `(file, lineno)`, a +#: SECOND, unregistered template appearing inside an already-declared +#: function/method collapses onto the SAME key -- the old code only recorded +#: key MEMBERSHIP (`seen_helper_keys: set`), which a second occurrence at an +#: already-seen key does not change, so it passed silently as long as its +#: `kind`/literal/`expr` happened to match the declared spec (exactly the +#: shape a shadowed comprehension variable produces). `sites` makes the COUNT +#: itself a declared, asserted fact -- `_classify_and_resolve` records every +#: lineno matched per key and hands it to [`_check_site_counts`], which fails, +#: naming the qualname, both counts, and every matched file:line, when the +#: real count differs from `sites`. Bump +#: `sites` ONLY after confirming each newly-listed line is a legitimate, +#: already-registered emit -- the same discipline `expected_calls`'s own +#: docstring insists on for `_resolve_helper`'s call-site count. +_RESOLVABLE_HELPERS: dict[tuple[str, str], dict] = { + ("tan/commands/bootstrap_cmd.py", "Log.take_issues"): dict( + # `Log.warn(self, code, message)`, drained by `take_issues` into + # this exact f-string -- every call site is `.warn(...)`. + prefix="bootstrap.", + expr="code", + attr="warn", + arg_index=0, + # 17, not 16, since dev's 518ac8c (tan-cli#334) split the single + # `zephyr-base-incompatible` warn into a found/else pair so the message + # can name the evidence. Two call sites, ONE code, already registered + # (`bootstrap.zephyr-base-incompatible`) -- checked before bumping, + # which is the whole point of the count: it forced the look. + expected_calls=17, + sites=1, + ), + ("tan/commands/bootstrap_cmd.py", "_refusal"): dict( + prefix="bootstrap.", + expr="code", + name="_refusal", + arg_index=1, + expected_calls=8, + sites=1, + ), + ("tan/commands/debug_config_cmd.py", "_failure"): dict( + prefix="debug-config.", + expr="code", + name="_failure", + arg_keyword="code", + expected_calls=2, + sites=1, + ), + ("tan/commands/sdk_cmd.py", "_fail"): dict( + prefix="sdk.", + expr="code", + name="_fail", + arg_keyword="code", + # tan-cli#351: was 5. `sdk list` without `--online` moved off `_fail` + # (which hardcodes exit_code=RUNTIME_FAILURE and severity "error") to a + # direct `_emit(...)` call with its own `warning`-severity Issue and + # exit_code=SUCCESS -- a normal state, not a failure. Its code, + # `sdk.network-required`, is still a LITERAL `Issue("sdk.network- + # required", ...)` first-arg site, so it is still covered, just by the + # plain-literal scan (shape 1) instead of this prefixing scan (shape 3). + expected_calls=4, + sites=1, + ), + ("tan/commands/validate_cmd.py", "validate.fail"): dict( + prefix="validate.", + expr="code", + name="fail", + arg_index=0, + expected_calls=5, + sites=1, + ), + ("tan/commands/doctor_cmd.py", "checks_to_issues"): dict( + # `check.code or f"doctor.{check.name}"` in `checks_to_issues()` -- + # the ceiling this bucket used to acknowledge instead of resolving + # (tan-cli#224 review): MEASURED at 48 `Check(...)` constructions, + # every one passing its `name` positionally and literally, zero + # non-literal, 20 distinct camelCase names (`_is_code_suffix` admits + # camelCase for exactly this reason -- see its own docstring). + # 54, not 48, as of tan-cli#91's `--fix` consent-gate work: 9 of the + # 54 also passed an explicit `code=` override (the frozen + # `bootstrap.*` spellings this class's own docstring documents, plus + # five NEW `doctor.fix-*` checks whose `name` is a dynamic + # `f"fix:{tool}"`, never a code-shaped literal at all) -- + # `skip_if_keyword="code"` excludes exactly those from this scan, + # since `check.code or f"doctor.{check.name}"` never evaluates their + # `name` for the code position at runtime; their real codes are + # captured separately by the plain `code=` keyword scan, the same + # mechanism every other literal `code=` site in this file already + # goes through. + # 55, and 10 skipped, as of tan-cli#360: `fix_installer_not_found_check` + # is the sixth `doctor.fix-*` Check, named `f"fix:{installer}"` (one + # per ABSENT INSTALLER, not per tool) and carrying an explicit + # `code="doctor.fix-installer-not-found"`. + prefix="doctor.", + expr="check.name", + name="Check", + arg_index=0, + skip_if_keyword="code", + expected_calls=55, + sites=1, + ), + ("tan/commands/west_forward_cmd.py", "_run_forward"): dict( + # `Issue(f"{subcommand}.failed", ...)` -- the MIRRORED shape + # (tan-cli#224 review): `subcommand` is `_run_forward`'s own + # parameter, closed to the three literal strings its three Typer + # callers (`migrate`/`lock`/`quality`) pass. TWO templates in this + # one function (the success arm and the `OSError` arm) share this + # exact spec and collapse to one declared entry here -- `sites=2` + # is what now RECORDS that fact instead of leaving it to a comment: + # before this field existed, nothing distinguished "one function, + # two known templates" from "one function, one known template plus + # a silently-collapsed unregistered one" -- see this table's own + # comment above. + kind="family", + suffix=".failed", + expr="subcommand", + name="_run_forward", + arg_index=0, + expected_calls=3, + sites=2, + ), + ("tan/commands/diff_cmd.py", "_emit_failure"): dict( + # `Issue(f"diff.{code}", ...)` inside `_emit_failure()` -- `code` is + # `_emit_failure`'s OWN keyword-only parameter, fed by 4 call sites in + # `diff()`: 2 literal suffixes (`board-yaml-missing`, `internal-failure` + # x2) and one forward, `code=failure.code` (the `except ParseFailure as + # failure` handler) -- `failure.code` is a bare suffix read off + # `ParseFailure`'s own raise sites (`pyyaml-unavailable`, + # `schema-violation`), declared in `_FORWARDER_SUFFIXES` below since + # `_resolve_helper` cannot read a plain (non-Starred) forwarded + # attribute directly. + prefix="diff.", + expr="code", + name="_emit_failure", + arg_keyword="code", + expected_calls=4, + sites=1, + ), + ("tan/commands/trace_cmd.py", "trace.fail"): dict( + # `Issue(f"trace.{code}", ...)` inside `fail()`, a nested function of + # the `trace` command -- `code` is `fail`'s own 2nd positional + # parameter (`exit_code, code, message, data, text_lines`), all 3 call + # sites literal (`sdk-root-unresolved`, `board-yaml-missing`, + # `internal-failure`). + prefix="trace.", + expr="code", + name="fail", + arg_index=1, + expected_calls=3, + sites=1, + ), +} + +#: `(file, exact substituted expression text)` -> `dict(suffixes=..., sites=...)` +#: -- `suffixes` is the closed, source-verified set of suffixes a FORWARDED +#: expression can carry, read from the origin, not guessed (see the module +#: docstring's bucket description). `warn(*skew)`/`warn(*ceiling)` are keyed +#: by the call shape rather than the bare unparsed name, because a `Starred` +#: argument is not a template substitution at all -- it is resolved per CALL +#: SITE inside `_resolve_helper`, not per f-string. +#: +#: `sites` carries the SAME discipline `_RESOLVABLE_HELPERS`/ +#: `_ACKNOWLEDGED_CEILINGS` do, for a REVIEWER-found reason specific to this +#: table: the key is `(file, expr)` -- no qualname, no enclosing-scope +#: information at all -- so a wholly UNRELATED function elsewhere in the same +#: file whose own f-string happens to substitute an identically-spelled +#: expression collapses onto the same entry and is silently resolved by +#: whatever suffix set that entry already declares. Measured: adding +#: `def _sneaky(refusal): return Issue(f"bootstrap.{refusal.code}", "error", +#: "smuggled")` to `bootstrap_cmd.py` and bumping `EXPECTED_TEMPLATE_COUNT` +#: 11 -> 12 gave "4 passed" before this field existed. For the three plain- +#: expression entries, `sites` is the exact count of [`_prefix_templates`] +#: matches at that `(file, expr)` key (asserted by [`_check_site_counts`], +#: the same function the other two tables share); for the two `*tuple` +#: entries, it is the exact count of Starred-argument call sites +#: [`_resolve_helper`] itself matches to that key -- a DIFFERENT scan +#: (`_resolve_helper`'s own `expected_calls` loop over calls to the ONE +#: helper each entry's `warn(*...)` shape names), fed into the SAME +#: `_check_site_counts` check by `_classify_and_resolve` merging both scans' +#: hits before calling it. +_FORWARDER_SUFFIXES: dict[tuple[str, str], dict] = { + # `Issue(f"bootstrap.{refusal.code}", ...)` in bootstrap_cmd.py -- no line + # number on purpose: this whole table was re-keyed off line numbers because + # they rot (tan-cli#224), and a comment that pins one rots the same way. + # forwards `check_prerequisites()`'s `PrereqFailure.code` + # (`tan/core/bootstrap.py`), which is exactly one of these four literals + # depending on which refusal branch it returned. + ("tan/commands/bootstrap_cmd.py", "refusal.code"): dict( + suffixes=frozenset({"prerequisites-missing", "python-not-runnable", "python-too-old", "venv-unusable"}), + sites=1, + ), + # `code=f"bootstrap.{venv_refusal.code}"` at doctor_cmd.py:662 forwards + # the SAME `PrereqFailure`, but `venv_refusal` there is only ever set + # from `posix_venv_unusable()` (doctor_cmd.py:2342) -- a strictly + # narrower value space than the bootstrap_cmd.py forward above. + ("tan/commands/doctor_cmd.py", "venv_refusal.code"): dict(suffixes=frozenset({"venv-unusable"}), sites=1), + # `code=failure.code` in `diff()`'s `except ParseFailure as failure:` + # handler, forwarded to `_emit_failure(...)`. Unlike every entry above, + # this key is matched from INSIDE `_resolve_helper`'s own call-site scan + # (see its docstring), not from a direct f-string occurrence + # `_prefix_templates` finds -- `failure.code` never appears in an f-string + # at all, it is a plain `code=` keyword value at one of `_emit_failure`'s + # 4 call sites. `sites=1` counts that one call site. The two suffixes are + # every literal `ParseFailure(...)` is raised with, read from source + # (diff_cmd.py's `_load_document`/`_parse_fields` raise sites). + ("tan/commands/diff_cmd.py", "failure.code"): dict( + suffixes=frozenset({"pyyaml-unavailable", "schema-violation"}), sites=1 + ), + # `Issue(f"support-bundle.{c.name}", ...)` in `_doctor_issues()` -- tan-cli + # #374 findings 3/4 rewrote this entry. `checks` there is + # `_debug_doctor_report(...)`'s own output (tan-cli#357; `_doctor_section`, + # the function this comment used to cite, was DELETED by that same diff), + # not `doctor_cmd._collect(...)`'s whole build/flash-readiness list -- so + # `c.name`'s real value space is NOT the 20-ish names + # `_RESOLVABLE_HELPERS[("tan/commands/doctor_cmd.py", "checks_to_issues")]` + # resolves for `tan doctor` at all. Re-derived from + # `_debug_doctor_report`'s own construction (`support_bundle_cmd.py`), + # narrowed to names whose `Check` can ever leave `pass`/`unknown` -- + # `_doctor_issues` only turns a `warn`/`fail` status into a wire issue, so + # a name that can never carry either (`workspaceRoot`: hardcoded `pass`; + # `lldb`: hardcoded `pass`, #131; the three `_extension_check(...)` names: + # hardcoded `unknown`, #102) would register a code this command can + # structurally never put on the wire: + # * `sdkRoot`, `boardYaml` -- the report's own fixed checks (fail/warn + # arms exist for both). + # * `jlinkBackend`/`openocdBackend`/`pyocdBackend` -- `_target_checks`'s + # `f"{server}Backend"` for the three servers `debug_launch._SERVER_ + # CHOICES` actually pairs with `zephyr-mcu`/`baremetal-mcu` today. + # `gdbserverBackend`/`noneBackend` are the SAME `f"{server}Backend"` + # construction for the other two `SERVER_KINDS` members -- not + # reachable through TODAY's `_SERVER_CHOICES` pairing (`is_server_ + # supported_for_target` refuses `--target-kind zephyr-mcu --server + # gdbserver` before `_target_checks` ever runs), but `server` is a + # plain `str` parameter with no narrower type at this call site, so a + # future widening of that pairing table would put either straight on + # the wire with nothing here to catch it. Declared now rather than + # left for the NEXT audit to rediscover. + # * `gdb` -- `_target_checks`'s `yocto-userspace` branch (warn arm + # exists). + # * `bootstrapManifest`, `hostPrerequisites`, `zephyrSdkAvailableForHost`, + # `longPaths`, `homePath` -- `_HOST_CHECK_ORDER`'s five names, harvested + # BY NAME from `doctor_cmd._collect(...)` (`_host_checks_from_doctor`). + # Also a genuine subset of doctor_cmd.py's own 20-ish resolved names + # (unsurprising: they are the identical `Check` objects, not a copy), + # but declared here independently rather than intersected from there, + # because `_HOST_CHECK_ORDER` -- support_bundle_cmd.py's own tuple -- + # is the thing that actually bounds what this command can harvest, and + # is the one artefact a reviewer changing that tuple will see. + ("tan/commands/support_bundle_cmd.py", "c.name"): dict( + suffixes=frozenset( + { + "sdkRoot", + "boardYaml", + "jlinkBackend", + "openocdBackend", + "pyocdBackend", + "gdbserverBackend", + "noneBackend", + "gdb", + "bootstrapManifest", + "hostPrerequisites", + "zephyrSdkAvailableForHost", + "longPaths", + "homePath", + } + ), + sites=1, + ), + # `Issue(f"validate.{result.outcome}", ...)` at validate_cmd.py:546 only + # ever fires inside `for message in result.messages`, and + # `outcome = OUTCOME_CLEAN if not messages else OUTCOME_SCHEMA_VIOLATION` + # (validate_cmd.py:272) means a non-empty `messages` implies + # `outcome == OUTCOME_SCHEMA_VIOLATION == "schema-violation"` always. + ("tan/commands/validate_cmd.py", "result.outcome"): dict(suffixes=frozenset({"schema-violation"}), sites=1), + # `log.warn(*skew)` / `log.warn(*ceiling)` in bootstrap_cmd.py (no line + # numbers -- see the note on the entry above) + # unpack the `(suffix, message)` pairs `python_floor_skew_warning()` and + # `python_ceiling_warning()` (`tan/core/bootstrap.py`) return. + ("tan/commands/bootstrap_cmd.py", "warn(*skew)"): dict(suffixes=frozenset({"python-floor-skew"}), sites=1), + ("tan/commands/bootstrap_cmd.py", "warn(*ceiling)"): dict( + suffixes=frozenset({"python-newer-than-verified"}), sites=1 + ), +} + +#: `(file, enclosing qualname)` -- the same stable identity +#: `_RESOLVABLE_HELPERS` keys on, for the same reason (see its own comment +#: above: a line number moves on any unrelated edit, tan-cli#224) -- -> a +#: `dict(reason=..., sites=...)`, the same `sites`-pinning discipline +#: `_RESOLVABLE_HELPERS` carries (see its own leading comment): `reason` is +#: why this template is deliberately NOT resolved, stated rather than +#: silently absent (matching `contract.rs`'s own "KNOWN CEILING" paragraph); +#: `sites` is the exact number of `_prefix_templates` matches acknowledged at +#: that key, so a SECOND, unregistered template arriving at an already- +#: acknowledged qualname is a named count mismatch, not a silent collapse -- +#: the same hole closed in `_RESOLVABLE_HELPERS`, applied here too since nothing +#: about "this key is an acknowledged ceiling instead of a resolved helper" +#: makes it immune to the same many-to-one key risk. +#: See the module docstring's third bucket. Held EMPTY today: `doctor_cmd +#: .py`'s `check.code or f"doctor.{check.name}"` was the sole entry until +#: tan-cli#224's own review measured its actual cost (48 `Check(...)` call +#: sites, ALL literal) and found it resolvable, not a real ceiling -- it +#: moved to `_RESOLVABLE_HELPERS` above. The bucket stays declared, not +#: deleted, so a genuinely out-of-scope FUTURE template has somewhere honest +#: to go rather than forcing a false resolution. +_ACKNOWLEDGED_CEILINGS: dict[tuple[str, str], dict] = {} + + +def _calls_matching(tree: ast.Module, *, attr: str | None = None, name: str | None = None) -> list[ast.Call]: + """Every `ast.Call` whose callee is `.attr(` (when `attr` is + given) or a bare `name(` (when `name` is given).""" + out: list[ast.Call] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if attr is not None and isinstance(func, ast.Attribute) and func.attr == attr: + out.append(node) + elif name is not None and isinstance(func, ast.Name) and func.id == name: + out.append(node) + return out + + +def _resolve_helper( + path: pathlib.Path, + *, + kind: str = "prefix", + prefix: str | None = None, + suffix: str | None = None, + attr: str | None = None, + name: str | None = None, + arg_index: int | None = None, + arg_keyword: str | None = None, + skip_if_keyword: str | None = None, + expected_calls: int, +) -> tuple[set[str], list[str], dict[tuple[str, str], list[int]]]: + """Scan every call to the declared helper in `path`, read the code + argument at `arg_index` (positional) or `arg_keyword`, and return + `(reconstructed codes, unresolved-descriptions, forwarder-hit linenos)`. + `kind="prefix"` (default) reconstructs `prefix + `; + `kind="family"` reconstructs ` + suffix` + (`west_forward_cmd.py`'s mirrored shape). + + `expected_calls` is asserted EXACTLY, mirroring `PREFIXING_SITES`'s own + pinned per-file counts (`contract.rs:663-682`) for the identical reason: + a floor lets a call site disappear unnoticed as long as enough others + remain to clear it. `skip_if_keyword`, when given, EXCLUDES a call from + both `parts` and `unresolved` (but still counts toward `expected_calls`) + when that call ALSO passes the named keyword -- `doctor_cmd.py`'s + `Check(name, ..., code=...)`: `checks_to_issues()`'s own + `check.code or f"doctor.{check.name}"` never evaluates `name` for the + code position once `code` is set, so scanning `name` there would either + misclassify a dynamic non-literal (`f"fix:{tool}"`) as unresolved or + (worse) quietly add a name that was never the emitted code to `parts` -- + this is a fact about THAT call, not a license to skip counting it. + + A `Starred` argument (`log.warn(*skew)`, unpacking a 2-tuple rather than + passing the code positionally) is looked up in `_FORWARDER_SUFFIXES` by + `f"{opener}(*{expr})"`. A non-`Starred`, non-literal argument (a plain + `Name`/`Attribute`, e.g. `code=failure.code`) is looked up the same table + by its bare unparsed text (`(rel, expr)`) -- the SAME key space + `_classify_and_resolve` already uses for a template whose substitution IS + the forward directly; here the forward sits one level further out, behind + a declared helper's OWN call site, so `_resolve_helper` is what has to + make the lookup instead. Anything neither resolves is reported UNRESOLVED + -- never a silent skip. Every matched forward's lineno (Starred or plain) + is recorded against its `_FORWARDER_SUFFIXES` key in the returned dict, so + `_classify_and_resolve` can feed it into the SAME `_check_site_counts` + that pins `_RESOLVABLE_HELPERS`/`_ACKNOWLEDGED_CEILINGS` -- for a Starred + forward this is the ONLY place its `sites` count can be measured from, + since [`_prefix_templates`] never sees a Starred call (it is not an + f-string); a plain forward CAN also be found directly by + [`_prefix_templates`] when the f-string substitutes it immediately (the + `refusal.code`/`venv_refusal.code`/`result.outcome` entries already in + `_FORWARDER_SUFFIXES`), but `failure.code` here never does -- the f-string + substitutes `_emit_failure`'s OWN `code` parameter, not `failure.code` + directly, so THIS scan is the only place that hit is ever counted. + """ + opener = attr or name + rel = _rel(path) + tree = _parse(path) + calls = _calls_matching(tree, attr=attr, name=name) + assert len(calls) == expected_calls, ( + f"{rel}: expected {expected_calls} call(s) to {opener}(...), found " + f"{len(calls)} -- a prefixing call site was ADDED (register its code, " + f"then bump this count) or REMOVED (a rename silently stopped this gate " + f"covering an emit). See _resolve_helper's docstring." + ) + parts: set[str] = set() + unresolved: list[str] = [] + forwarder_hits: dict[tuple[str, str], list[int]] = {} + for call in calls: + if skip_if_keyword is not None and any(kw.arg == skip_if_keyword for kw in call.keywords): + continue + + arg: ast.expr | None + if arg_keyword is not None: + arg = next((kw.value for kw in call.keywords if kw.arg == arg_keyword), None) + elif arg_index is not None and len(call.args) > arg_index: + arg = call.args[arg_index] + else: + arg = None + + if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and _is_code_suffix(arg.value): + parts.add(arg.value) + continue + if isinstance(arg, ast.Starred): + key = (rel, f"{opener}(*{ast.unparse(arg.value)})") + declared = _FORWARDER_SUFFIXES.get(key) + if declared is None: + unresolved.append( + f"{rel}:{call.lineno} -- `{opener}(*{ast.unparse(arg.value)})` unpacks a " + f"tuple this scan cannot read a suffix from directly. Add {key!r} to " + f"_FORWARDER_SUFFIXES with the known suffix set, read from source." + ) + else: + parts |= declared["suffixes"] + forwarder_hits.setdefault(key, []).append(call.lineno) + continue + if arg is not None and not isinstance(arg, ast.Constant): + plain_key = (rel, ast.unparse(arg)) + declared = _FORWARDER_SUFFIXES.get(plain_key) + if declared is not None: + parts |= declared["suffixes"] + forwarder_hits.setdefault(plain_key, []).append(call.lineno) + continue + got = ast.unparse(arg) if arg is not None else "no matching argument" + unresolved.append( + f"{rel}:{call.lineno} -- `{opener}(...)`'s code argument is not a literal " + f"({got}). Either pass a literal suffix, or if it forwards a code from " + f"elsewhere, resolve the value space by hand and add it to " + f"_FORWARDER_SUFFIXES." + ) + if kind == "prefix": + assert prefix is not None + return {prefix + s for s in parts}, unresolved, forwarder_hits + assert suffix is not None + return {s + suffix for s in parts}, unresolved, forwarder_hits + + +def _check_site_counts( + declared: dict[tuple[str, str], dict], seen: dict[tuple[str, str], list[int]], bucket: str +) -> list[str]: + """The count check the qualname/expr re-keying gap needs, shared by all + THREE many-to-one declared tables (`_RESOLVABLE_HELPERS`, + `_ACKNOWLEDGED_CEILINGS`, `_FORWARDER_SUFFIXES`): `declared` maps a + `(file, identity)` key -- `identity` is an enclosing qualname for the + first two tables, a substituted expression for the third -- to a spec + carrying a `sites` int, the exact number of matches that key is declared + to cover. `seen` is what THIS RUN actually found there, keyed the same + way (from [`_prefix_templates`] for the qualname-keyed tables and the + plain-expression `_FORWARDER_SUFFIXES` entries; from + [`_resolve_helper`]'s own Starred-argument scan for the `*tuple` + `_FORWARDER_SUFFIXES` entries -- see that function's docstring). Any + mismatch, in EITHER direction, is returned as a message naming the key, + both counts, and every matched file:line -- never silently absorbed the + way a bare membership check (`key in declared`) would. + + Deliberately extracted to a MODULE-LEVEL function, not left as a closure + inside `_classify_and_resolve` (tan-cli#224 review, MAJOR finding): a + closure captures its enclosing scope and cannot be called with synthetic + input by a test, so the only thing that had ever watched this exact + assertion fire was a hand-edit of the production tree -- which does not + survive into CI, precisely the tan-cli#275 lesson this file cites about + itself elsewhere. Returns a list of messages rather than raising or + mutating a list captured from the caller, so a test can call this + directly and assert on the return value -- see + `test_check_site_counts_flags_a_declared_vs_actual_mismatch`, which does + exactly that for both the resolved-helper shape and the (empty-in- + production, doubly unproven without this) acknowledged-ceiling shape. + """ + messages: list[str] = [] + for key, spec in declared.items(): + rel, identity = key + expected = spec["sites"] + lines = sorted(seen.get(key, [])) + if len(lines) == expected: + continue + where = ", ".join(f"{rel}:{ln}" for ln in lines) or "none" + if len(lines) > expected: + what_to_do = ( + "a NEW, unregistered site landed at this same declared key instead of " + "being caught -- read each line above, confirm which is genuinely new, " + "register its code (add it to contract/issue-codes.json, the same as any " + "other emit site), and only then bump `sites` to match." + ) + else: + what_to_do = ( + "a declared site disappeared -- it was renamed, moved to a different " + "function, or removed; update or delete this entry (and " + "EXPECTED_TEMPLATE_COUNT if the total template count changed, not just " + "this key's share of it)." + ) + messages.append( + f"{bucket}[{key!r}] (at {identity}) declares sites={expected} but this run " + f"found {len(lines)} at {rel}: {where} -- {what_to_do}" + ) + return messages + + +def _classify_and_resolve( + templates: dict[str, list[tuple[int, str, str, str, str]]], +) -> tuple[set[str], list[str], list[str]]: + """Walk every discovered template, classify it into one of the three + declared buckets, and resolve the ones that are classified. Returns + `(reconstructed codes, unresolved call sites, unclassified templates)`. + """ + codes: set[str] = set() + unresolved: list[str] = [] + unclassified: list[str] = [] + # `(file, qualname)` -> every `_prefix_templates` lineno matched at that + # key, for _RESOLVABLE_HELPERS and _ACKNOWLEDGED_CEILINGS respectively. + # DELIBERATELY not a `set` of keys seen (the old `seen_helper_keys`): the + # key is `(file, qualname)`, a many-to-one mapping (every template inside + # one function shares it), so recording only MEMBERSHIP cannot notice a + # SECOND, unregistered template landing on an already-declared key -- the + # exact hole a reviewer found in the qualname re-keying (tan-cli#224): a + # shadowed comprehension variable inside `_refusal` produces a second + # `f"bootstrap.{code}"` whose kind/literal/expr all match the existing + # declaration, so it would pass the asserts below unnoticed. Recording + # every lineno lets the per-key count check further down compare the + # REAL count against each entry's declared `sites` and name the exact + # new (or missing) line. + seen_helper_lines: dict[tuple[str, str], list[int]] = {} + seen_ceiling_lines: dict[tuple[str, str], list[int]] = {} + # `(file, expr)` -> every lineno matched at that _FORWARDER_SUFFIXES key, + # from BOTH sources that can match one: a plain-expression template found + # right here in this loop, or (merged in below, after the + # `_RESOLVABLE_HELPERS` loop runs) a Starred call site `_resolve_helper` + # itself matches. Same many-to-one reasoning as the two dicts above, for + # the reviewer-found reason `_FORWARDER_SUFFIXES`'s own leading comment + # gives: this key has no qualname or call-site scope at all, so it is + # if anything an EASIER key for an unrelated site to collapse onto. + seen_forwarder_lines: dict[tuple[str, str], list[int]] = {} + + for rel, sites in templates.items(): + for lineno, literal, expr, kind, qualname in sites: + # `key` is `(file, enclosing qualname)` -- NOT `(file, lineno)` -- + # so an unrelated edit that merely shifts this site's line cannot + # desync it from its declaration; see _RESOLVABLE_HELPERS' own + # comment (tan-cli#224). `lineno` still rides along on every + # message below, purely so a human can jump straight to the site. + key = (rel, qualname) + if key in _ACKNOWLEDGED_CEILINGS: + seen_ceiling_lines.setdefault(key, []).append(lineno) + continue + if key in _RESOLVABLE_HELPERS: + spec = _RESOLVABLE_HELPERS[key] + spec_kind = spec.get("kind", "prefix") + assert spec_kind == kind, ( + f"{rel}:{lineno} (in {qualname}) -- _RESOLVABLE_HELPERS declared " + f"kind={spec_kind!r} but the template now reads kind={kind!r}; the " + f"f-string changed shape -- update the declaration." + ) + declared_literal = spec["prefix"] if kind == "prefix" else spec["suffix"] + assert declared_literal == literal and spec["expr"] == expr, ( + f"{rel}:{lineno} (in {qualname}) -- _RESOLVABLE_HELPERS declared " + f"literal={declared_literal!r} expr={spec['expr']!r} but the template now " + f"reads literal={literal!r} expr={expr!r}; the f-string changed shape -- " + f"update the declaration." + ) + seen_helper_lines.setdefault(key, []).append(lineno) + continue + fwd_key = (rel, expr) + fwd = _FORWARDER_SUFFIXES.get(fwd_key) + if fwd is not None: + seen_forwarder_lines.setdefault(fwd_key, []).append(lineno) + suffixes = fwd["suffixes"] + codes |= {literal + s for s in suffixes} if kind == "prefix" else {s + literal for s in suffixes} + continue + shape = f'f"{literal}{{{expr}}}"' if kind == "prefix" else f'f"{{{expr}}}{literal}"' + unclassified.append( + f"{rel}:{lineno} (in {qualname}) -- new prefix template {shape} is not in " + f"_RESOLVABLE_HELPERS, _FORWARDER_SUFFIXES or _ACKNOWLEDGED_CEILINGS. " + f"Classify it in one of the three (see this file's module docstring)." + ) + + for key, spec in _RESOLVABLE_HELPERS.items(): + rel = key[0] + site_codes, site_unresolved, site_forwarder_hits = _resolve_helper( + TAN.parent / rel, + kind=spec.get("kind", "prefix"), + prefix=spec.get("prefix"), + suffix=spec.get("suffix"), + attr=spec.get("attr"), + name=spec.get("name"), + arg_index=spec.get("arg_index"), + arg_keyword=spec.get("arg_keyword"), + skip_if_keyword=spec.get("skip_if_keyword"), + expected_calls=spec["expected_calls"], + ) + codes |= site_codes + unresolved.extend(site_unresolved) + for fwd_key, lines in site_forwarder_hits.items(): + seen_forwarder_lines.setdefault(fwd_key, []).extend(lines) + + unclassified.extend(_check_site_counts(_RESOLVABLE_HELPERS, seen_helper_lines, "_RESOLVABLE_HELPERS")) + unclassified.extend(_check_site_counts(_ACKNOWLEDGED_CEILINGS, seen_ceiling_lines, "_ACKNOWLEDGED_CEILINGS")) + unclassified.extend(_check_site_counts(_FORWARDER_SUFFIXES, seen_forwarder_lines, "_FORWARDER_SUFFIXES")) + + return codes, unresolved, unclassified + + +def _all_prefix_templates() -> dict[str, list[tuple[int, str, str, str, str]]]: + return {_rel(path): _prefix_templates(path) for path in sorted(TAN.rglob("*.py"))} + + +def _all_literal_codes() -> tuple[dict[str, list[str]], list[str]]: + """`(code -> the files it was found in, every unresolved code-position + site across the whole tree)` -- never silently dropped, see + `_literal_codes_in_file`.""" + found: dict[str, list[str]] = {} + unresolved: list[str] = [] + for path in sorted(TAN.rglob("*.py")): + codes, site_unresolved = _literal_codes_in_file(path) + for code in codes: + found.setdefault(code, []).append(_rel(path)) + unresolved.extend(site_unresolved) + return found, unresolved + + +def _missing(emitted: set[str], registered: set[str]) -> list[str]: + """The pure diff both the real gate and its self-test below share, so the + self-test exercises the SAME comparison the real assertion makes rather + than a reimplementation of it.""" + return sorted(emitted - registered) + + +def test_every_prefix_template_is_classified(): + """Non-vacuity + drift pin for auto-discovery itself (tan-cli#224): the + template COUNT is exact, and every template found must resolve to one of + the three declared buckets. A template appearing with none of the three + is the exact hole this file exists to close for a FUTURE prefixing + helper, the same way #224 (and its own review remediation) closed it for + the ones that already existed. + """ + templates = _all_prefix_templates() + total = sum(len(sites) for sites in templates.values()) + found_lines = "\n".join( + ( + f' {rel}:{lineno} (in {qualname}) f"{literal}{{{expr}}}"' + if kind == "prefix" + else f' {rel}:{lineno} (in {qualname}) f"{{{expr}}}{literal}"' + ) + for rel, sites in templates.items() + for lineno, literal, expr, kind, qualname in sites + ) + assert total == EXPECTED_TEMPLATE_COUNT, ( + f'found {total} f-string prefix templates (`f"family.{{code}}"` shape) ' + f"across tan/, expected {EXPECTED_TEMPLATE_COUNT}. Fewer means one was " + f"rewritten (update the count AND check whether a _RESOLVABLE_HELPERS / " + f"_FORWARDER_SUFFIXES / _ACKNOWLEDGED_CEILINGS entry is now stale); more " + f"means a NEW prefixing helper appeared -- classify it in one of the " + f"three buckets (see this file's module docstring), then bump this " + f"count. Found:\n{found_lines}" + ) + _, _, unclassified = _classify_and_resolve(templates) + assert not unclassified, "Unclassified prefix template(s):\n " + "\n ".join(unclassified) + + +def test_every_emitted_issue_code_is_registered(): + """The pair `crates/tan-cli/tests/contract.rs::every_emitted_issue_code_is_registered` + + `::every_prefixed_issue_code_is_registered` ported to the surface that + actually ships (tan-cli#224) -- see the module docstring for the full + design. LITERAL codes and PREFIXED codes are both required to appear in + `contract/issue-codes.json` at some status. + """ + registered = _registered_codes() + + literal, literal_unresolved = _all_literal_codes() + # Never a silent drop (tan-cli#224's own review finding): a code-position + # argument this scan cannot resolve to a literal, and that is not a + # declared forward, is reported by file:line -- not quietly absent from + # `literal` with no trace. + assert not literal_unresolved, ( + f"{len(literal_unresolved)} code-position argument(s) could not be resolved to a " + "literal and are not declared in _KNOWN_CODE_FORWARDS or _FULL_CODE_CALLABLES:\n " + + "\n ".join(literal_unresolved) + ) + # Non-vacuity: a scanner that silently matched nothing would pass this + # gate while checking nothing at all -- the tan-cli#219 failure mode. + assert len(literal) > 30, ( + f"found only {len(literal)} literal issue codes across tan/ -- the scan is " + f"broken, and a broken scan makes this gate vacuous" + ) + + templates = _all_prefix_templates() + prefixed, unresolved, unclassified = _classify_and_resolve(templates) + assert not unclassified, ( + "Unclassified prefix template(s) -- see test_every_prefix_template_is_classified:\n " + + "\n ".join(unclassified) + ) + assert not unresolved, ( + f"{len(unresolved)} prefixed emit site(s) could not be resolved:\n " + "\n ".join(unresolved) + ) + + emitted = set(literal) | prefixed + missing = _missing(emitted, registered) + assert not missing, ( + f"{len(missing)} issue code(s) are emitted by python/tan but appear in " + "contract/issue-codes.json at NO status:\n" + + "\n".join( + f" {c} (sites: {', '.join(literal.get(c, ['assembled by a prefixing helper']))})" for c in missing + ) + + "\n\nAn unregistered code is ungated on both sides of the seam at once: this " + "repo's registry-driven checks never see it, and the published " + "envelope-contract.json is built from that same registry, so alp-sdk-vscode " + 'cannot see it either. Add each one with "status": "reserved" and ' + '"consumer": "none" -- that costs nothing, since a reserved code may ' + 'still be renamed freely. Use "frozen" ONLY once a consumer actually binds ' + "to it." + ) + + +def test_gate_rejects_a_deliberately_unregistered_code(): + """tan-cli#275's own lesson, applied to this gate specifically: an + assertion nobody has ever watched fail is not proven to fire. This test + is that watch -- it exercises the SAME `_missing` comparison + `test_every_emitted_issue_code_is_registered` makes, against the REAL + registry, with one fabricated code injected into the emitted set. + + Manually verified both ways while writing this file (not just asserted + here): with the fabricated code removed from the injected set the + assertion below fails (`AssertionError`), and restored it passes -- so + this genuinely exercises the failure path, not a tautology that can + never go red. + """ + registered = _registered_codes() + real_emitted = set(_all_literal_codes()[0]) + # Not a real code -- guaranteed absent from the registry both by + # construction (this spelling names itself as one) and because a real + # code always has a plausible family; "zzz-tan-cli-224-self-test" is + # neither. + fabricated = "zzz-tan-cli-224-self-test.never-registered" + assert fabricated not in registered, "the fabricated self-test code collided with a real one -- pick another" + + injected = real_emitted | {fabricated} + offenders = _missing(injected, registered) + assert fabricated in offenders, ( + "the gate's own diff did not flag a deliberately unregistered code -- " + "this gate cannot fail, which per tan-cli#275 means it is not a gate" + ) + + # And the negative: with nothing injected, that same fabricated spelling + # must NOT appear -- proving the assertion is sensitive to the input, + # not unconditionally red. + offenders_clean = _missing(real_emitted, registered) + assert fabricated not in offenders_clean + + +def test_check_site_counts_flags_a_declared_vs_actual_mismatch(): + """tan-cli#224 review, MAJOR finding: `_check_site_counts` used to be a + closure inside `_classify_and_resolve`, so nothing could exercise it + directly -- the only thing that had ever watched its assertion fire was a + hand-edit of the production tree, which per tan-cli#275 (this file's own + standing lesson, applied to itself) does not count as proof an assertion + fires. Extracting it to a top-level function (see its own docstring) + makes it directly callable with synthetic input, the same self-test + discipline `test_gate_rejects_a_deliberately_unregistered_code` and + `test_prefix_template_scan_finds_a_fresh_synthetic_site` already apply to + their own targets. + + Exercises BOTH halves `_check_site_counts` is used for. `_RESOLVABLE_HELPERS`' + shape first, with a synthetic `declared` spec claiming `sites=1` against a + synthetic `seen` map recording 2 linenos at that key -- mirroring, at the + unit level, the exact shadowed-comprehension exploit `sites` was added to + catch (two templates silently sharing one declared key). Then + `_ACKNOWLEDGED_CEILINGS`'s shape, doubly unproven before this test: that + table is EMPTY in production today, so nothing had ever driven a real + value through this exact code path for that bucket at all, synthetic or + otherwise. + """ + key = ("tan/commands/selftest_cmd.py", "_selftest_helper") + declared = {key: dict(prefix="selftest.", expr="code", sites=1)} + seen_mismatched = {key: [10, 20]} + + messages = _check_site_counts(declared, seen_mismatched, "_RESOLVABLE_HELPERS") + assert len(messages) == 1, messages + msg = messages[0] + # Names the key... + assert "_RESOLVABLE_HELPERS[('tan/commands/selftest_cmd.py', '_selftest_helper')]" in msg, msg + # ...and BOTH counts: the declared expectation and the actual finding. + assert "declares sites=1" in msg, msg + assert "found 2" in msg, msg + assert "tan/commands/selftest_cmd.py:10" in msg and "tan/commands/selftest_cmd.py:20" in msg, msg + + # The _ACKNOWLEDGED_CEILINGS half -- same function, same synthetic + # mismatch, different bucket label -- doubly unproven beforehand since + # that table carries zero real entries to ever drive this path. + ceiling_messages = _check_site_counts(declared, seen_mismatched, "_ACKNOWLEDGED_CEILINGS") + assert len(ceiling_messages) == 1, ceiling_messages + assert ceiling_messages[0].startswith("_ACKNOWLEDGED_CEILINGS[("), ceiling_messages[0] + assert "declares sites=1" in ceiling_messages[0] and "found 2" in ceiling_messages[0], ceiling_messages[0] + + # And the negative: a `seen` count that matches `declared` produces no + # message at all -- proving this is sensitive to the mismatch, not + # unconditionally red. + assert _check_site_counts(declared, {key: [10]}, "_RESOLVABLE_HELPERS") == [] + + # And the mirrored direction: a declared key with NOTHING seen at all + # (the "a declared site disappeared" branch) is exactly as loud. + vanished = _check_site_counts(declared, {}, "_RESOLVABLE_HELPERS") + assert len(vanished) == 1, vanished + assert "declares sites=1" in vanished[0] and "found 0" in vanished[0], vanished[0] + + +def test_prefix_template_scan_finds_a_fresh_synthetic_site(tmp_path: pathlib.Path): + """A second self-test, at the AST-mechanics level rather than the + registry-diff level: [`_prefix_templates`] and [`_literal_codes_in_file`] + are exercised here against SYNTHETIC source containing codes neither has + ever seen. Proves the SCANNER notices a new site, not just that the diff + logic notices a missing registration. + + Written under pytest's own `tmp_path`, NOT under `TAN` (tan-cli#224 + review): `_literal_codes_in_file`/`_prefix_templates` take an arbitrary + path (`_rel` falls back to the path unchanged outside `tan/`, see its own + docstring), so nothing here needs to live inside the real package -- and a + scratch file that DID would be one interrupted run away from surviving + into `python/tan/` itself (a stray file in the very tree every other test + in this file globs with `TAN.rglob("*.py")`), the production-tree escape + this fix closes. + """ + synthetic = tmp_path / "selftest_scratch.py" + synthetic.write_text( + "from __future__ import annotations\n" + "\n" + 'SELFTEST_CONST = "selftest.const-code"\n' + "\n" + "\n" + "def emit_one(reg):\n" + ' return Issue(SELFTEST_CONST, "error", "x")\n' + "\n" + "\n" + "def emit_two(code):\n" + ' return Issue(f"selftestfamily.{code}", "error", "x")\n' + "\n" + "\n" + "def emit_three():\n" + ' return Check("n", "fail", "d", code="selftest.kwarg-code")\n', + encoding="utf-8", + ) + literal, unresolved = _literal_codes_in_file(synthetic) + assert literal == {"selftest.const-code", "selftest.kwarg-code"}, literal + assert unresolved == [], unresolved + + templates = _prefix_templates(synthetic) + assert templates == [(11, "selftestfamily.", "code", "prefix", "emit_two")], templates diff --git a/python/tests/gates/test_frozen_issue_codes.py b/python/tests/gates/test_frozen_issue_codes.py index ba7490cd..7217eeb0 100644 --- a/python/tests/gates/test_frozen_issue_codes.py +++ b/python/tests/gates/test_frozen_issue_codes.py @@ -1,26 +1,44 @@ # SPDX-License-Identifier: Apache-2.0 -"""Frozen issue-code gate: pins the Python-side spelling of every `frozen` -`issues[].code` string, and bans re-use of the one `retired` spelling. +"""Python-side registry->source gate: every `contract/issue-codes.json` entry +whose emission site lives in `python/` must still be emitted there, and the one +`retired` spelling must never be re-introduced. `contract/issue-codes.json` is the single source: alp-sdk-vscode matches five codes with `===` (tan-cli#106) and that match FAILS OPEN -- an unrecognised code is indistinguishable from "no problem" on the consumer side, so a rename or removal here is silent on both sides with CI green. -`crates/tan-cli/tests/contract.rs::frozen_issue_codes` already gates the Rust -emission sites against the same registry; this mirrors its two live -guarantees -- a frozen code's literal must still exist at EVERY one of its -emission sites, and a retired code's spelling must never be re-introduced -anywhere -- for the Python executor, which emits the same codes from -different files. - -Not mirrored: the Rust gate's `reserved`-status check. A `reserved` code's -`consumer` is `"none"` (`contract/issue-codes.json`'s own definition of the -status) -- nobody matches it with `===` yet, so renaming or dropping it costs -nothing on the wire, unlike a `frozen` or `retired` spelling. The shared -registry itself is already kept honest against source drift by -`contract.rs::frozen_issue_codes` on the Rust side; pinning every reserved -code's Python site here too would just be duplicate bookkeeping for no -wire-safety gain. + +WHO CHECKS WHAT (tan-cli#363). `crates/tan-cli/tests/contract.rs::frozen_issue_codes` +walks the SAME registry, but only checks entries whose `emittedBy` names Rust +source: there, `literal` is a verbatim slice of `crates/`, and `crates/` is +frozen (it ships to nobody -- the release assets are PyInstaller freezes of +`python/tan`, tan-cli#271), so a substring needle cannot rot under a reformat. +Every entry whose `emittedBy` names a `python/` path is DELEGATED to this file, +which parses the emission with `ast` instead. The Rust gate asserts that +delegation is total and that this file still defines +[`test_every_python_side_registry_entry_is_still_emitted`]; this file asserts +the mirror (nothing is owned by neither side), so repointing an entry at a +third kind of path reddens both. + +WHY the split exists rather than one shared needle. The Rust gate used to +substring-check python-side entries too, and their `literal` field is not a +needle at all -- it is a prose DESCRIPTION of the emission shape +(`Issue("sdk.network-required", "warning", ...)`, +`f"support-bundle.{c.name}" where c.name == "boardYaml"`). `sdk.network-required` +is what surfaced it (tan-cli#363): its emission was rewrapped from one line to +four, nothing about the code changed, and `cargo test --locked --workspace` +went red on Linux, Windows AND macOS claiming a live registered code was gone. +That was never one stale row -- MEASURED while fixing it, 42 of the 198 +python-side entries failed the identical substring check; the Rust gate only +ever reported the first because `assert!` panics. Formatting had become part of +the wire contract. An `ast` parse cannot be broken by a line wrap, a named +argument, or a comment. + +WHAT THIS DOES NOT PROVE, stated plainly (inherited from the Rust gate's own +wording): that the code still REACHES the wire. It proves the spelling still +exists at the emission site. A refactor that deletes the whole refusal branch +but leaves the string behind passes here; the command's own tests cover the +emission, this covers the spelling. """ from __future__ import annotations @@ -29,48 +47,53 @@ import json import pathlib +# `python/tests/gates/` is on `sys.path` under pytest's default prepend import +# mode (no `__init__.py` here), so the sibling gate imports as a plain module. +# Imported for its DECLARED table only -- no scan runs at import time, so this +# file does not inherit that gate's `expected_calls` pins, which need a hand +# bump whenever an unrelated call site is added. +from test_every_issue_code_is_registered import _FORWARDER_SUFFIXES, _rel + #: `contract/` lives at the repo root, one level above `python/`. REGISTRY = pathlib.Path(__file__).resolve().parents[3] / "contract" / "issue-codes.json" +REPO_ROOT = REGISTRY.parent.parent TAN = pathlib.Path(__file__).resolve().parents[2] / "tan" -#: Every site each FROZEN code's literal must be found at, on the Python -#: side. Most codes have exactly one; `bootstrap.prerequisites-missing`, -#: `bootstrap.python-not-runnable` and `bootstrap.python-too-old` each have -#: two -- a bare-suffix `PrereqFailure` in `core/bootstrap.py` AND a -#: full-dotted `Check(code=...)` in `commands/doctor_cmd.py` that also -#: reaches `issues[].code` verbatim (`doctor_cmd.py::checks_to_issues`) -- -#: and EVERY pinned site must hold, not just one. +#: Every file each FROZEN code must still be emitted from, on the Python side, +#: relative to `python/tan/`. Most codes have exactly one; +#: `bootstrap.prerequisites-missing`, `bootstrap.python-not-runnable` and +#: `bootstrap.python-too-old` each have two -- a bare-suffix `PrereqFailure` in +#: `core/bootstrap.py` AND a full-dotted `Check(code=...)` in +#: `commands/doctor_cmd.py` that also reaches `issues[].code` verbatim +#: (`doctor_cmd.py::checks_to_issues`) -- and EVERY listed site must hold, not +#: just one. #: -#: `bootstrap.yocto-host` pins a literal wider than the bare suffix on -#: purpose: `commands/bootstrap_cmd.py` has a severity-`warning` sibling -#: (the mixed-board case) that reuses the same `"yocto-host"` suffix at a -#: different call site, and the registry's own `note` for this code says the -#: consumer also requires severity `error` -- the two sites are not -#: interchangeable, so the pin must not match both. +#: Was a `(file, source-literal)` pair until tan-cli#363. Three of the eight +#: needles spanned two tokens (`ExitCode.VALIDATION_FAILURE, "yocto-host"`, +#: `code="bootstrap.python-too-old"`), so a formatter rewrapping the call would +#: have reported a live frozen code as gone -- the exact defect #363 filed +#: against the Rust gate, sitting here too. The literals are gone; the check is +#: [`_unemitted_reason`], which parses. +#: +#: `bootstrap.yocto-host` has a severity-`warning` sibling in the same file +#: reusing the same `"yocto-host"` suffix (the mixed-board case), and the +#: registry's own `note` says the consumer also requires severity `error` -- the +#: two sites are not interchangeable. A file-level "is it still emitted" check +#: cannot tell them apart, so that one discrimination is pinned separately and +#: structurally by +#: [`test_the_yocto_host_refusal_site_keeps_its_error_severity`]. #: #: `contract/issue-codes.json`'s own `emittedBy`/`literal` fields point at the -#: Rust sources instead (that is what `contract.rs::frozen_issue_codes` -#: checks) -- this is the Python-side equivalent pin, kept independently so a -#: rename on EITHER side is caught by its own language's gate. -FROZEN_LOCATIONS: dict[str, list[tuple[str, str]]] = { - "bootstrap.yocto-host": [ - ("commands/bootstrap_cmd.py", 'ExitCode.VALIDATION_FAILURE, "yocto-host"'), - ], - "bootstrap.prerequisites-missing": [ - ("core/bootstrap.py", '"prerequisites-missing"'), - ("commands/doctor_cmd.py", 'code="bootstrap.prerequisites-missing"'), - ], - "presets.sdk-root-unresolved": [ - ("commands/presets_cmd.py", '"presets.sdk-root-unresolved"'), - ], - "bootstrap.python-not-runnable": [ - ("core/bootstrap.py", '"python-not-runnable"'), - ("commands/doctor_cmd.py", 'code="bootstrap.python-not-runnable"'), - ], - "bootstrap.python-too-old": [ - ("core/bootstrap.py", '"python-too-old"'), - ("commands/doctor_cmd.py", 'code="bootstrap.python-too-old"'), - ], +#: Rust sources for these five instead (that is what +#: `contract.rs::frozen_issue_codes` checks) -- this is the Python-side +#: equivalent pin, kept independently so a rename on EITHER side is caught by +#: its own language's gate. +FROZEN_LOCATIONS: dict[str, list[str]] = { + "bootstrap.yocto-host": ["commands/bootstrap_cmd.py"], + "bootstrap.prerequisites-missing": ["core/bootstrap.py", "commands/doctor_cmd.py"], + "presets.sdk-root-unresolved": ["commands/presets_cmd.py"], + "bootstrap.python-not-runnable": ["core/bootstrap.py", "commands/doctor_cmd.py"], + "bootstrap.python-too-old": ["core/bootstrap.py", "commands/doctor_cmd.py"], } @@ -83,61 +106,132 @@ def _strip_comments(text: str) -> str: return "\n".join(line for line in text.splitlines() if not line.strip().startswith("#")) -def _strip_docstrings(text: str) -> str: - """Blank out module/class/function docstring line ranges. A docstring is - Python's comment form too, but `_strip_comments` only catches `#` lines -- - without this, a literal that survives only in prose (not in executable - code) would still count as a hit. - - Raises `SyntaxError` as-is on an unparseable file rather than falling back - to the unstripped text: a fallback here would let a frozen literal that - now lives ONLY in a docstring read as a live hit, which is exactly the - fail-open this gate exists to close. The pinned files are this package's - own sources, so a parse failure is itself worth failing the gate on.""" - tree = ast.parse(text) - lines = text.splitlines() - nodes: list[ast.AST] = [tree] - nodes.extend( - n - for n in ast.walk(tree) - if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) - ) - for node in nodes: - body = getattr(node, "body", None) - if not body: - continue - first = body[0] - if ( - isinstance(first, ast.Expr) - and isinstance(first.value, ast.Constant) - and isinstance(first.value.value, str) - ): - end = first.end_lineno or first.lineno - for lineno in range(first.lineno, end + 1): - lines[lineno - 1] = "" - return "\n".join(lines) +def _retired_code_lines(text: str) -> str: + """Comments only, deliberately NOT docstrings, and deliberately still a + TEXT scan rather than the `ast` parse the frozen/reserved direction now + uses -- both asymmetries point the same way. A retired spelling that + survives only in a docstring should still count as a hit: a false FAILURE + (flagging prose) is the safe side of this gate, where for the + still-emitted checks a false PASS (missing a real removal) is the + dangerous side. And "this exact quoted token must appear NOWHERE" is a + question a substring scan answers correctly -- a line wrap cannot split a + single quoted string literal, so the tan-cli#363 shape-blindness has no + grip here.""" + return _strip_comments(text) -def _frozen_code_lines(text: str) -> str: - """Comments AND docstrings blanked out -- airtight for the FROZEN check: a - renamed call site must not read as unchanged just because the old - spelling still sits in a nearby docstring.""" - return _strip_comments(_strip_docstrings(text)) +def _string_constants(path: pathlib.Path) -> set[str]: + """Every `str` constant in `path`'s AST, minus docstrings and any other + bare string statement. + The formatting-immune replacement for the substring needles tan-cli#363 + broke: `ast` sees a call's arguments the same whether they sit on one line + or six, whether they are positional or named, and it never sees a `#` + comment at all. Docstrings ARE `ast` nodes, so they are excluded here by + hand (an `ast.Expr` whose value is a string constant) -- otherwise a code + that now survives only in prose about itself would read as live, the + fail-open this gate exists to close. -def _retired_code_lines(text: str) -> str: - """Comments only, deliberately NOT docstrings -- the opposite direction - from the frozen check on purpose. Here, a retired spelling that survives - only in a docstring should still count as a hit: a false FAILURE (flagging - prose) is the safe side of this gate, where for the frozen check above a - false PASS (missing a real rename) is the dangerous side.""" - return _strip_comments(text) + Deliberate ceiling: this is every string constant in the file, not only + the ones in a code position. Narrowing it further means reproducing + `test_every_issue_code_is_registered.py`'s declared `_RESOLVABLE_HELPERS`/ + `_FULL_CODE_CALLABLES` tables and their hand-bumped call counts, and that + gate already owns the source->registry direction with them. For THIS + direction the question is only "did the spelling disappear", where a + coincidental unrelated string of the same spelling is a rare false PASS -- + strictly less bad than the false FAILURE the old text needle produced on + every reformat, and never worse than the substring scan it replaces, which + matched the same spelling anywhere in the file too. + """ + tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"), filename=str(path)) + docstrings = { + id(node.value) + for node in ast.walk(tree) + if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) + } + return { + node.value + for node in ast.walk(tree) + if isinstance(node, ast.Constant) and isinstance(node.value, str) and id(node) not in docstrings + } + + +def _assembled_suffixes() -> dict[str, frozenset[str]]: + """Collapses the sibling gate's `(file, expression)` keys to `file`: this + direction only asks whether the FILE can still produce the code, not which + forwarded expression inside it does.""" + merged: dict[str, frozenset[str]] = {} + for (rel, _expr), spec in _FORWARDER_SUFFIXES.items(): + merged[rel] = merged.get(rel, frozenset()) | spec["suffixes"] + return merged + + +#: `tan/...` relative path -> every suffix a code assembled IN THAT FILE from a +#: value defined ELSEWHERE can carry. Read straight off the sibling gate's +#: `_FORWARDER_SUFFIXES`, the declared, source-verified home of exactly this +#: fact, rather than re-derived here: `support_bundle_cmd.py`'s +#: `Issue(f"support-bundle.{c.name}", ...)` takes its 20 `c.name`s from +#: `doctor_cmd.py`'s own `Check(...)` list, so those suffixes are genuinely not +#: string constants in the file that emits them. +_ASSEMBLED_SUFFIXES: dict[str, frozenset[str]] = _assembled_suffixes() -def _missing_frozen_literals(codes: list[dict], tan_root: pathlib.Path) -> list[str]: - """Every FROZEN code whose pinned literal cannot be found at EVERY site - `FROZEN_LOCATIONS` names for it -- unpinned, moved, or renamed at any one - of possibly several live emission sites.""" +def _unemitted_reason(path: pathlib.Path, code: str) -> str | None: + """`None` when `code` is still emitted by `path`, else a one-line reason. + + Four shapes are accepted, and between them they cover all 198 python-side + registry entries (measured, tan-cli#363) -- the registry's own policy is + that a dynamically assembled code is registered whole even though no whole + literal exists anywhere, so the assembled shapes have to be recognised, not + ignored: + + 1. WHOLE code as a string constant -- `Issue("sdk.network-required", ...)` + however it is wrapped, `code="kconfig.emit-failed"`, or a module + constant (`DEFERRED_ISSUE_CODE = "cli.command-deferred"`). + 2. BARE SUFFIX as a string constant -- the dominant shape: a prefixing + helper is handed `"venv-unusable"` / `"yocto-host"` / `"boardYaml"` + and assembles `family.` + suffix itself. + 3. MIRRORED family template -- `west_forward_cmd.py`'s + `f"{subcommand}.failed"`, where the FAMILY is substituted and the + suffix is fixed: both halves must be present as constants (`"migrate"` + and `".failed"`), so a stale `bogus.failed` row still fails. + 4. CROSS-FILE assembled -- the fixed prefix (`"support-bundle."`) is a + constant here but the suffix is defined in another module; the suffix + must appear in that file's declared `_ASSEMBLED_SUFFIXES` set, so a + fabricated `support-bundle.bogus` row still fails. + """ + if not path.is_file(): + return f"{code}: {path.name} does not exist" + try: + constants = _string_constants(path) + except SyntaxError as exc: + # Raised as a FAILURE, never swallowed: a fallback to a text scan here + # would reintroduce exactly the shape-blindness this replaces, and the + # scanned files are this package's own sources, so a parse failure is + # itself worth failing on. + return f"{code}: {path.name} does not parse ({exc}) -- cannot verify the emission" + family, _, suffix = code.partition(".") + if code in constants or suffix in constants: + return None + if family in constants and f".{suffix}" in constants: + return None + # `_rel` (the sibling gate's, reused) is the one spelling `_ASSEMBLED_SUFFIXES` + # is keyed by, and it falls back to the path unchanged outside `tan/` -- so a + # synthetic self-test file under pytest's `tmp_path` is reported, not crashed on. + rel = _rel(path) + if f"{family}." in constants and suffix in _ASSEMBLED_SUFFIXES.get(rel, frozenset()): + return None + return ( + f"{code}: neither {code!r} nor the bare suffix {suffix!r} is a string constant in " + f"{rel} (nor an assembled-code shape this scan recognises) -- the emission is gone, " + f"or it moved to another file and the registry's `emittedBy` is now stale" + ) + + +def _missing_frozen_emissions(codes: list[dict]) -> list[str]: + """Every FROZEN code no longer emitted at EVERY site `FROZEN_LOCATIONS` + names for it -- unpinned, moved, or renamed at any one of possibly several + live emission sites.""" offenders: list[str] = [] for entry in codes: if entry["status"] != "frozen": @@ -148,20 +242,10 @@ def _missing_frozen_literals(codes: list[dict], tan_root: pathlib.Path) -> list[ # test_the_pin_set_matches_the_registrys_frozen_codes' job -- one # owner per break, not duplicated here. continue - for rel, literal in FROZEN_LOCATIONS[code]: - path = tan_root / rel - if not path.is_file(): - offenders.append(f"{code}: pinned file {rel} does not exist") - continue - try: - text = _frozen_code_lines(path.read_text(encoding="utf-8", errors="replace")) - except SyntaxError as exc: - offenders.append(f"{code}: {rel} does not parse ({exc}) -- cannot verify pin") - continue - if literal not in text: - offenders.append( - f"{code}: {literal!r} no longer found in {rel} outside comments/docstrings" - ) + for rel in FROZEN_LOCATIONS[code]: + reason = _unemitted_reason(TAN / rel, code) + if reason is not None: + offenders.append(reason) return offenders @@ -183,6 +267,17 @@ def _reused_retired_spellings(codes: list[dict], tan_root: pathlib.Path) -> list return offenders +def _python_side_entries(codes: list[dict]) -> list[dict]: + """Every registry entry the Rust gate delegates here: status `frozen` or + `reserved` (a `retired` code has no emission site by definition) with an + `emittedBy` under `python/`.""" + return [ + e + for e in codes + if e["status"] in ("frozen", "reserved") and (e.get("emittedBy") or "").startswith("python/") + ] + + def test_frozen_and_retired_issue_codes_stay_pinned(): codes = _registry()["issueCodes"] frozen = [c for c in codes if c["status"] == "frozen"] @@ -191,7 +286,7 @@ def test_frozen_and_retired_issue_codes_stay_pinned(): "registry has no frozen/retired codes -- this gate would be vacuous" ) - offenders = _missing_frozen_literals(codes, TAN) + _reused_retired_spellings(codes, TAN) + offenders = _missing_frozen_emissions(codes) + _reused_retired_spellings(codes, TAN) assert not offenders, ( "A frozen or retired issue code drifted on the Python side.\n" "FROZEN codes: alp-sdk-vscode matches them with `===` and that match " @@ -221,3 +316,203 @@ def test_the_pin_set_matches_the_registrys_frozen_codes(): f" pinned here but not frozen in the registry: {sorted(pinned - registry_frozen)}\n" f" frozen in the registry but not pinned here: {sorted(registry_frozen - pinned)}" ) + + +def test_every_python_side_registry_entry_is_still_emitted(): + """tan-cli#363: the half of the registry->source direction `contract.rs` + delegates here, because Rust cannot import Python's `ast` and a hand-rolled + Python parser over there would be a weaker copy of the one that already + lives in this directory. + + `contract.rs::frozen_issue_codes` pins THIS function by name and fails if + it disappears, so deleting it cannot leave 198 entries owned by neither + gate. The mirror assertion is below: every frozen/reserved entry must sit + under `crates/` (checked there) or `python/` (checked here), so repointing + one at a third kind of path reddens both sides rather than falling into a + gap between them. + """ + codes = _registry()["issueCodes"] + + unowned = [ + f"{e['code']}: emittedBy={e.get('emittedBy')!r}" + for e in codes + if e["status"] in ("frozen", "reserved") + and not (e.get("emittedBy") or "").startswith(("crates/", "python/")) + ] + assert not unowned, ( + "issue-codes.json entr(ies) whose `emittedBy` is under neither `crates/` " + "(checked by contract.rs::frozen_issue_codes) nor `python/` (checked " + "here) -- they are gated by NOTHING. Point `emittedBy` at the real " + "emission site:\n " + "\n ".join(unowned) + ) + + entries = _python_side_entries(codes) + # Non-vacuity: the whole python-side half vanishing (a registry rewrite, a + # path convention change) would otherwise make this gate pass by finding + # nothing to check -- the tan-cli#275 lesson. 198 today; a floor, not a pin, + # because every new command legitimately adds rows. + assert len(entries) > 100, ( + f"only {len(entries)} python-side registry entries found -- the `emittedBy` " + f"convention changed and this gate is now checking almost nothing" + ) + + offenders = [ + reason + for e in entries + if (reason := _unemitted_reason(REPO_ROOT / e["emittedBy"], e["code"])) is not None + ] + assert not offenders, ( + f"{len(offenders)} registry entr(ies) name a python/ emission site that no longer " + "emits them. Either restore the emission, or update contract/issue-codes.json " + "(for a `reserved` code that is a free rename -- nothing matches it with `===` " + "yet; for a `frozen` one it is a wire break, see this file's docstring):\n " + + "\n ".join(offenders) + ) + + +def test_the_yocto_host_refusal_site_keeps_its_error_severity(): + """The one discrimination [`_unemitted_reason`] structurally cannot make. + + `bootstrap_cmd.py` emits the `"yocto-host"` suffix TWICE: once through + `_refusal(ExitCode.VALIDATION_FAILURE, "yocto-host", ...)` (the frozen + `bootstrap.yocto-host` at severity `error`, which the consumer requires -- + see the registry's own `note`) and once through + `log.warn("yocto-host", ...)` for the mixed-board case, deliberately the + same spelling at severity `warning`. A file-level "is this suffix still + emitted here" check passes on either one alone, so the ERROR site is + pinned here by shape instead: the call, its argument POSITION and its exit + code, all read from the AST rather than from one formatting of the line + (which is what the old `'ExitCode.VALIDATION_FAILURE, "yocto-host"'` text + needle did, and what tan-cli#363 is about). + """ + tree = ast.parse((TAN / "commands/bootstrap_cmd.py").read_text(encoding="utf-8"), filename="bootstrap_cmd.py") + sites = [ + call + for call in ast.walk(tree) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Name) + and call.func.id == "_refusal" + and len(call.args) > 1 + and isinstance(call.args[1], ast.Constant) + and call.args[1].value == "yocto-host" + ] + assert len(sites) == 1, ( + f"expected exactly 1 `_refusal(..., \"yocto-host\", ...)` call in " + f"commands/bootstrap_cmd.py, found {len(sites)} at lines " + f"{[c.lineno for c in sites]} -- the frozen error-severity site was removed " + f"(a wire break: alp-sdk-vscode matches bootstrap.yocto-host with `===`), or a " + f"second one appeared and it is no longer obvious which the consumer sees." + ) + exit_code = ast.unparse(sites[0].args[0]) + assert exit_code == "ExitCode.VALIDATION_FAILURE", ( + f"commands/bootstrap_cmd.py:{sites[0].lineno} -- the frozen `bootstrap.yocto-host` " + f"refusal now exits with {exit_code}, not ExitCode.VALIDATION_FAILURE. The registry's " + f"note pins severity `error` for this code because the consumer requires it; the " + f"severity-`warning` sibling (`log.warn(\"yocto-host\", ...)`, the mixed-board case) " + f"is a DIFFERENT verdict that happens to share the spelling." + ) + + +def test_the_scan_reads_a_multiline_issue_call(tmp_path: pathlib.Path): + """The tan-cli#363 regression fixture: the exact multiline `Issue(...)` + shape `sdk_cmd.py` emits `sdk.network-required` from. + + Written under pytest's `tmp_path`, not under `TAN` -- a scratch file inside + the production package is one interrupted run away from surviving into + `python/tan/`, which every other test here globs (the same reasoning + `test_every_issue_code_is_registered.py`'s own synthetic-source test + records). + + Asserts BOTH directions, so this cannot pass by accident: the AST scan + finds the code, and the needle the registry carried when #363 was filed + does NOT match the fixture -- proving the fixture really reproduces the + reported shape rather than an accidentally single-line form that would + have satisfied the old scanner too. + """ + fixture = tmp_path / "multiline_emission.py" + fixture.write_text( + "def _offline_list(json_mode):\n" + " return _emit(\n" + " json_mode=json_mode,\n" + " data=_list_data([]),\n" + " issues=[\n" + " Issue(\n" + ' "sdk.network-required",\n' + ' "warning",\n' + ' "`sdk list` reports the Alp SDK releases published upstream "\n' + ' "on GitHub -- there is no local/offline copy to answer from. "\n' + ' "Add --online to fetch them.",\n' + " )\n" + " ],\n" + " exit_code=ExitCode.SUCCESS,\n" + " )\n", + encoding="utf-8", + newline="\n", + ) + + assert _unemitted_reason(fixture, "sdk.network-required") is None + + old_needle = 'Issue("sdk.network-required", "warning", ...)' + assert old_needle not in fixture.read_text(encoding="utf-8"), ( + "this fixture no longer reproduces the tan-cli#363 shape -- the whole point is " + "that the emission is WRAPPED, so the single-line needle cannot match it" + ) + + # And the negative, so the fixture cannot pass by matching anything: a code + # the fixture does not emit is reported, with the file named. + absent = _unemitted_reason(fixture, "sdk.never-emitted-here") + assert absent is not None and "sdk.never-emitted-here" in absent, absent + + +def test_the_scan_rejects_a_code_that_is_no_longer_emitted(tmp_path: pathlib.Path): + """tan-cli#275's standing lesson applied to this gate: an assertion nobody + has watched fail is not proven to fire. Drives [`_unemitted_reason`] -- + the single predicate BOTH the frozen check and the python-side registry + check route through -- across every accept/reject shape it declares, with + synthetic source, so no hand-edit of the production tree is needed to know + it can go red. + """ + source = tmp_path / "shapes.py" + source.write_text( + '"""A docstring naming sdk.only-in-prose and "only-in-prose-suffix"."""\n' + "\n" + 'WHOLE = "sdk.whole-literal"\n' + "\n" + "\n" + "def emit(log, subcommand):\n" + ' log.warn("bare-suffix", "x") # family assembled by the helper\n' + ' log.fail(f"{subcommand}.failed", "x") # mirrored family template\n' + ' log.note(f"support-bundle.{check}", "x")\n' + ' return ["migrate"]\n', + encoding="utf-8", + newline="\n", + ) + + # Accepted, one per declared shape (see `_unemitted_reason`'s docstring). + assert _unemitted_reason(source, "sdk.whole-literal") is None + assert _unemitted_reason(source, "bootstrap.bare-suffix") is None + assert _unemitted_reason(source, "migrate.failed") is None + + # Rejected: a spelling that exists ONLY in the docstring. This is the + # fail-open the `_string_constants` docstring exclusion closes -- without + # it, prose about a deleted code would keep its registry row looking live. + assert _unemitted_reason(source, "sdk.only-in-prose") is not None + assert _unemitted_reason(source, "bootstrap.only-in-prose-suffix") is not None + + # Rejected: a code nothing in the file mentions at all. + gone = _unemitted_reason(source, "sdk.deleted-last-week") + assert gone is not None and "deleted-last-week" in gone, gone + + # Rejected: the cross-file assembled shape is NOT a blanket pass for its + # family -- `support-bundle.` is a constant here, but the suffix must still + # be one the declared `_ASSEMBLED_SUFFIXES` set for that file carries, and + # this synthetic path has no declared set at all. + assert _unemitted_reason(source, "support-bundle.bogus") is not None + + # Rejected: a pinned file that does not exist (a moved emission site), and + # one that does not parse -- both reported, never silently skipped. + assert _unemitted_reason(tmp_path / "absent.py", "sdk.whole-literal") is not None + broken = tmp_path / "broken.py" + broken.write_text("def (\n", encoding="utf-8", newline="\n") + unparseable = _unemitted_reason(broken, "sdk.whole-literal") + assert unparseable is not None and "does not parse" in unparseable, unparseable diff --git a/python/tests/gates/test_global_flags_gate.py b/python/tests/gates/test_global_flags_gate.py new file mode 100644 index 00000000..a4a07fff --- /dev/null +++ b/python/tests/gates/test_global_flags_gate.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#261: every registered command must PARSE the oracle's global +argument surface (`tan.core.global_flags.GLOBAL_FLAGS`), even where a +command's own logic never reads a given flag's value. + +This is the gate the issue itself asked for, verbatim: "the right shape is a +shared registration mechanism so a global flag is declared once and applies +everywhere... write the gate that keeps it true: a test that enumerates the +command surface and asserts every command accepts the global set." Before +`tan.core.global_flags.accept_global_flags` existed, 99 (flag, command) pairs +across 17 commands raised Click's own "No such option" -- measured against +the v0.4.1 oracle, which accepts every one of them (`crates/tan-cli/src/ +cli.rs`'s `GlobalArgs`, `#[arg(long, global = true, ...)]`). This test is +what stops an eighteenth command from being added the same way: a NEW command +missing a flag here fails THIS test, not a fresh re-measurement someone has +to remember to run. + +Probed with a trailing `--help` on every invocation, never a bare run: a +command that would otherwise dial a real Zephyr build, spawn `west`, or write +a project file must never run for real just because this gate exercised its +flag surface. `--help` is Click's own EAGER short-circuit, but -- measured, +both directions -- it does not paper over a genuinely unrecognised option: +`tan validate --bogus --help` and `tan validate --help --bogus` both still +report "No such option: --bogus" rather than silently printing help, so a +trailing `--help` is not a weaker probe than a bare invocation would be, only +a SAFE one. +""" +from __future__ import annotations + +import pytest +from typer.testing import CliRunner + +from tan.cli import _SUBCOMMAND_NAMES, app +from tan.core.global_flags import GLOBAL_FLAG_ARITY, GLOBAL_FLAGS + +runner = CliRunner() + +#: Case-insensitive substrings Click's own usage-error rendering uses for "you +#: gave me a flag I do not know" -- the SAME set `cli.py`'s own docstrings and +#: this port's oracle-comparison scripts key off. `--help` never emits any of +#: these on its own, so a match unambiguously means the FLAG was rejected, not +#: that help text happened to mention the word. +_REJECTION_MARKERS = ("no such option", "unexpected argument", "unrecognized argument") + + +def _rejected(output: str) -> bool: + lowered = output.lower() + return any(marker in lowered for marker in _REJECTION_MARKERS) + + +def _probe_argv(command: str, flag: str) -> list[str]: + argv = [command, flag] + if GLOBAL_FLAG_ARITY[flag] == 1: + argv.append("dummy-value") + argv.append("--help") + return argv + + +@pytest.mark.parametrize("command", sorted(_SUBCOMMAND_NAMES)) +@pytest.mark.parametrize("flag", GLOBAL_FLAGS) +def test_every_registered_command_accepts_every_global_flag(command: str, flag: str): + argv = _probe_argv(command, flag) + result = runner.invoke(app, argv) + assert not _rejected(result.output), ( + f"`tan {command} {flag}` is rejected as an unrecognised option:\n" + f"{result.output}\n" + "Every command tan registers must parse the oracle's global argument " + "surface (tan-cli#261) -- add the missing flag by calling " + f"`{command} = accept_global_flags({command})` at the end of its " + "*_cmd.py module (tan.core.global_flags), not by hand-declaring it." + ) + + +def test_global_flags_gate_actually_covers_the_full_registered_surface(): + """A parametrised gate that silently shrank to zero cases would report + green while checking nothing -- this is the canary for exactly that.""" + assert len(_SUBCOMMAND_NAMES) >= 30, ( + f"only {len(_SUBCOMMAND_NAMES)} commands registered; expected the " + "full ~32-command surface (tan.cli._SUBCOMMAND_NAMES). If a command " + "was intentionally removed, update this floor in the same change." + ) + assert len(GLOBAL_FLAGS) == len(GLOBAL_FLAG_ARITY) >= 10, ( + "tan.core.global_flags.GLOBAL_FLAGS / GLOBAL_FLAG_ARITY shrank below " + "the oracle's known GlobalArgs field count -- see cli.rs:24-73." + ) diff --git a/python/tests/gates/test_no_new_hardware_facts.py b/python/tests/gates/test_no_new_hardware_facts.py index c62f8467..53fb0d28 100644 --- a/python/tests/gates/test_no_new_hardware_facts.py +++ b/python/tests/gates/test_no_new_hardware_facts.py @@ -50,6 +50,16 @@ "tree by SKU FAMILY, which is the vendor branching I-26 forbids. Retires when " "the template catalogue declares its family mapping in metadata." ), + "renode_sim.py": ( + "DEBT: WIRED_CONSOLE_SKUS hardcodes E1M-AEN801 -- the SKUs whose retired-Python " + "`_SIM_BOARD_PROFILES` console was a wired hardware UART rather than the " + "`ram_console_buf` RAM ring. Landed with the tan-cli#77 --sim-mode port. It is a " + "real vendor fact in tan and the gate is right to flag it; it is allowlisted " + "rather than dropped because deleting it would make the silent-UART warning " + "claim the firmware printed nothing, when the truth is that the wired-console " + "path is deferred. Retires when the sim descriptor's console kind is read from " + "the SoM preset instead of a SKU list -- the same fix `scaffold.py` below waits on." + ), "models.py": ( "DEBT: a literal 7-bit I2C address -- the clearest breach in the tree. Rode " "along with the planner relocation; belongs in metadata." @@ -86,6 +96,14 @@ "the generated output stays diffable against it; naming real examples is " "the point, the same category explain_cmd.py and bootstrap.py are OK for." ), + "pinmux_cmd.py": ( + "OK: `_FAMILY_PREFIX_TABLE` maps an E1M-* SKU prefix to its " + "metadata/pinmux/.yaml stem (the family this command's own " + "table lookup is FOR), and the `--sku` option's help text names a real " + "example SKU -- the same 'naming real parts is the feature' category " + "explain_cmd.py/bootstrap.py are OK for, not a fact tan decides " + "anything from silently." + ), "zephyr_board.py": ( "DEBT: two `E1M-EVK` mentions inside EMITTED devicetree prose (the generated " "`-pinctrl.dtsi` and `.dts` say which carrier wires the console). Not a fact " diff --git a/python/tests/installers/test_installer_release_layout.py b/python/tests/installers/test_installer_release_layout.py new file mode 100644 index 00000000..3a41007c --- /dev/null +++ b/python/tests/installers/test_installer_release_layout.py @@ -0,0 +1,617 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The installers must install whatever shape the RESOLVED release publishes. + +tan-cli#356. #349 switched the release to PyInstaller ``--onedir`` archives and +both installers then requested the new names UNCONDITIONALLY -- +``tan-.tar.gz`` from ``install.sh``, ``tan-.zip`` from +``install.ps1``. No published tag has those assets, so the documented install +command 404'd on every tag that exists: ``v0.4.1`` (what ``latest`` resolves to) +and the ``v0.5.0-rc4`` pre-release both publish RAW binaries. + +The fixture releases below mirror the REAL published asset lists name for name +(``gh release view --repo alplabai/tan-cli --json assets``, read while +writing this), so a pass here is a claim about the real thing rather than about +a shape invented for the test: + +=============== =========================================================== +``v0.4.1`` 8 raw assets -- the last Rust release, and today's ``latest`` +``v0.5.0-rc4`` 4 raw assets -- the ``--onefile`` freeze; no musl, no + linux/arm64 +``v0.5.0`` 4 ARCHIVES -- the first tag that publishes them. **Not cut + yet**, which is exactly why archive extraction is covered by a + fixture and not by a live download. +=============== =========================================================== + +Everything is served from a local HTTP server through ``TAN_INSTALL_BASE_URL``, +so nothing here touches the network -- except the two bare-``latest`` tests, +which ask GitHub only which tag ``latest`` is and then fetch that tag from the +fixture. They skip cleanly when that answer cannot be had. + +Both scripts are run for real, as scripts. Asserting on their transcripts would +not have caught #356 (the old code printed a perfectly well-formed "downloading" +line before 404ing); what is asserted is which files ended up in the install dir. +""" + +from __future__ import annotations + +import functools +import hashlib +import http.server +import os +import re +import shutil +import subprocess +import sys +import tarfile +import threading +import zipfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +INSTALL_SH = REPO_ROOT / "install.sh" +INSTALL_PS1 = REPO_ROOT / "install.ps1" + +#: The FIRST tag that publishes ``--onedir`` archives. It is a real, planned +#: tag that has not been cut -- deliberately NOT ``v0.5.0-rc4``, whose published +#: assets are raw (that mistake is the documentation half of #356). When v0.5.0 +#: ships, this constant is the one thing that has to stay true. +FIRST_ARCHIVE_TAG = "v0.5.0" + +#: Every tag that exists today, all of which publish raw binaries. +RAW_TAGS = ("v0.4.1", "v0.5.0-rc4") + +#: tag -> the asset names that tag really publishes, verbatim. ``checksums.txt`` +#: and ``envelope-contract.json`` are omitted from the values: the first is +#: generated below from these names, and the second is never fetched by either +#: installer. +RELEASES: dict[str, tuple[str, ...]] = { + "v0.4.1": ( + "tan-aarch64-apple-darwin", + "tan-aarch64-pc-windows-msvc.exe", + "tan-aarch64-unknown-linux-gnu", + "tan-aarch64-unknown-linux-musl", + "tan-x86_64-apple-darwin", + "tan-x86_64-pc-windows-msvc.exe", + "tan-x86_64-unknown-linux-gnu", + "tan-x86_64-unknown-linux-musl", + ), + "v0.5.0-rc4": ( + "tan-aarch64-apple-darwin", + "tan-x86_64-apple-darwin", + "tan-x86_64-pc-windows-msvc.exe", + "tan-x86_64-unknown-linux-gnu", + ), + FIRST_ARCHIVE_TAG: ( + "tan-aarch64-apple-darwin.tar.gz", + "tan-x86_64-apple-darwin.tar.gz", + "tan-x86_64-pc-windows-msvc.zip", + "tan-x86_64-unknown-linux-gnu.tar.gz", + ), +} + +#: What the fixture's POSIX executable prints, so a test can tell the payload +#: apart from the launcher script the archive layout installs in its place. +FIXTURE_VERSION_LINE = "tan 9.9.9-fixture" + +_LATEST_RE = re.compile(r"latest is (\S+?)\.?$", re.MULTILINE) + + +# --------------------------------------------------------------------------- +# Fixture release building +# --------------------------------------------------------------------------- +def _write_posix_executable(path: Path) -> None: + """A shell script, not a copied binary: install.sh only ever has to *exec* + it, and a script is the one payload that is guaranteed to run on whatever + POSIX host the suite lands on.""" + path.write_text(f'#!/bin/sh\necho "{FIXTURE_VERSION_LINE}"\n', encoding="utf-8", newline="\n") + path.chmod(0o755) + + +def _write_windows_executable(path: Path) -> None: + """A REAL console .exe is required, and there is no way around it: + install.ps1 finishes by running ``& $dest --version`` and DELETES an install + whose binary cannot run (that refusal is deliberate -- see the comment at + the end of the script), so a batch file renamed ``.exe`` would fail the very + check these tests need to reach. + + ``sys.executable`` is the one guaranteed-present real executable on the + host, it answers ``--version`` with exit 0, and a bare copy of it -- no DLLs + beside it -- still does (measured on CPython 3.12.10 for Windows). Its + output is "Python X.Y.Z" rather than a tan version string, which is why the + Windows assertions below are about which FILES landed, never about what the + installed program printed. + """ + shutil.copyfile(sys.executable, path) + + +def _stage_onedir(parent: Path, exe_name: str) -> Path: + """The ``tan/`` tree a ``--onedir`` freeze archives: the executable plus + ``_internal/``, matching ``build_binary.sh``'s + ``shutil.make_archive(..., base_dir="tan")``.""" + root = parent / "tan" + (root / "_internal").mkdir(parents=True) + (root / "_internal" / "fixture-runtime.txt").write_text( + "stands in for the PyInstaller runtime\n", encoding="utf-8", newline="\n" + ) + exe = root / exe_name + if exe_name.endswith(".exe"): + _write_windows_executable(exe) + else: + _write_posix_executable(exe) + return root + + +def _build_asset(path: Path, staging: Path) -> None: + if path.name.endswith(".zip"): + tree = _stage_onedir(staging, "tan.exe") + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + for item in sorted(tree.rglob("*")): + zf.write(item, item.relative_to(tree.parent).as_posix()) + elif path.name.endswith(".tar.gz"): + tree = _stage_onedir(staging, "tan") + with tarfile.open(path, "w:gz") as tf: + tf.add(tree, arcname="tan") + elif path.name.endswith(".exe"): + _write_windows_executable(path) + else: + _write_posix_executable(path) + + +def _build_release(tag_dir: Path, assets: tuple[str, ...], staging: Path) -> None: + tag_dir.mkdir(parents=True) + lines = [] + for name in assets: + asset = tag_dir / name + work = staging / tag_dir.name / name + work.mkdir(parents=True) + _build_asset(asset, work) + digest = hashlib.sha256(asset.read_bytes()).hexdigest() + # Two spaces, exactly as sha256sum writes it and as the real published + # checksums.txt has it -- both installers match on the SECOND + # whitespace-separated field, so the separator is part of the contract. + lines.append(f"{digest} {name}") + (tag_dir / "checksums.txt").write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") + + +class _QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *args): # noqa: A003 - silence one request per line + pass + + +@pytest.fixture(scope="module") +def release_server(tmp_path_factory): + """A local stand-in for ``https://github.com//releases/download``. + + Module-scoped: building the fixtures copies ``sys.executable`` a handful of + times, and every test wants the same three releases. + """ + root = tmp_path_factory.mktemp("releases") + staging = tmp_path_factory.mktemp("staging") + for tag, assets in RELEASES.items(): + _build_release(root / tag, assets, staging) + + handler = functools.partial(_QuietHandler, directory=str(root)) + httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + try: + yield f"http://127.0.0.1:{httpd.server_address[1]}" + finally: + httpd.shutdown() + httpd.server_close() + + +# --------------------------------------------------------------------------- +# Running the installers +# --------------------------------------------------------------------------- +PWSH = shutil.which("pwsh") or shutil.which("powershell") + +windows_only = pytest.mark.skipif( + os.name != "nt" or not PWSH, reason="install.ps1 needs Windows + PowerShell" +) +posix_only = pytest.mark.skipif( + os.name == "nt", reason="install.sh refuses a Windows host and points at install.ps1" +) + + +def _run( + argv: list[str], base_url: str, home: Path, extra_env: dict[str, str | None] | None = None +) -> subprocess.CompletedProcess: + env = { + **os.environ, + "TAN_INSTALL_BASE_URL": base_url, + # The conftest already repoints HOME/USERPROFILE at a tmp dir; pinned + # again here because these two subprocesses would otherwise be the only + # things in the suite that could write to a real dotfile. + "HOME": str(home), + "USERPROFILE": str(home), + } + # `None` deletes rather than sets -- the arm64-Windows Outcome-2 test needs + # PROCESSOR_ARCHITEW6432 genuinely absent, not merely unset in this dict, + # since install.ps1 prefers it over PROCESSOR_ARCHITECTURE when present and + # a stray inherited value would silently pick a different arch than the one + # the test is asking for. + for key, value in (extra_env or {}).items(): + if value is None: + env.pop(key, None) + else: + env[key] = value + return subprocess.run(argv, env=env, capture_output=True, text=True, timeout=180) + + +def _install_ps1(base_url: str, dest: Path, home: Path, *args: str, extra_env: dict[str, str | None] | None = None): + # -NoModifyPath, always: without it install.ps1 appends $Dir to the USER + # Path, which is a persistent registry write on the developer's own machine + # -- a test must not leave a pile of dead tmp dirs on someone's PATH. + return _run( + [ + PWSH, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", str(INSTALL_PS1), "-Dir", str(dest), "-NoModifyPath", *args, + ], + base_url, + home, + extra_env, + ) + + +def _install_sh(base_url: str, dest: Path, home: Path, *args: str, extra_env: dict[str, str | None] | None = None): + # --no-modify-path for the same reason: the rc-file append is not what these + # tests are about, and $HOME is redirected above anyway. + return _run( + ["sh", str(INSTALL_SH), "--dir", str(dest), "--no-modify-path", *args], + base_url, + home, + extra_env, + ) + + +def _fake_uname(tmp_path: Path, os_name: str, arch: str) -> Path: + """A minimal ``uname`` staged ahead of the real one on PATH, so a test can + drive install.sh's OS/arch detection (``uname -s`` / ``uname -m``) to a + combination the real CI runner is not -- e.g. aarch64 Linux -- without an + actual arm64 runner. Used only to reach the tan-cli#356 Outcome-2 refusal + for a real published tag that genuinely lacks that platform's asset; every + OTHER test in this file exercises the real host's real arch.""" + bin_dir = tmp_path / "fake-uname-bin" + bin_dir.mkdir() + script = bin_dir / "uname" + script.write_text( + "#!/bin/sh\n" + 'case "$1" in\n' + f' -s) echo "{os_name}" ;;\n' + f' -m) echo "{arch}" ;;\n' + f' *) echo "{os_name}" ;;\n' + "esac\n", + encoding="utf-8", + newline="\n", + ) + script.chmod(0o755) + return bin_dir + + +def _fake_sudo(unlock_target: Path, calls_log: Path) -> Path: + """A `sudo` stub for exercising install.sh:375-393's elevated-permission + branch (`as_root`) without real root, which is off the table in CI and in + this sandbox alike. Real `sudo` on an unattended runner either hangs on a + password prompt with no TTY to answer it or is not configured passwordless + -- neither is something a test can drive. + + The trick this stub relies on needs no elevation at all: POSIX `chmod` + requires only OWNERSHIP of the target, never the write permission bit + itself. The same unprivileged test process that locked `unlock_target` + down to 0555 to force the sudo branch can therefore legitimately hand its + own write bit back and then run the wrapped command for real -- the same + end state real `sudo` would produce, sourced from ownership instead of + root. Every invocation (its full argv) is appended to `calls_log`, so a + test can assert the elevation path actually ran rather than merely that + the install succeeded. + """ + bin_dir = unlock_target.parent / "fake-sudo-bin" + bin_dir.mkdir(exist_ok=True) + script = bin_dir / "sudo" + script.write_text( + "#!/bin/sh\n" + f'printf \'%s\\n\' "$*" >> "{calls_log}"\n' + f'chmod u+w "{unlock_target}" 2>/dev/null || true\n' + 'exec "$@"\n', + encoding="utf-8", + newline="\n", + ) + script.chmod(0o755) + return bin_dir + + +def _skip_unless_latest_is_a_fixture_tag(result: subprocess.CompletedProcess) -> None: + """`latest` is resolved against the real GitHub, so which tag comes back is + not this suite's to decide. SKIP -- never fail -- only when it could not be + resolved at all (offline, or the API's 60/hr unauthenticated limit): that + is a real external unavailability, not a gap in this suite. + + FAIL, loudly, when it resolved to a tag `RELEASES` (:69) does not carry. + That dict is a hardcoded snapshot of what a handful of real tags publish; + the day a new one ships and becomes `latest`, silently skipping forever is + exactly the "a skip passes too" failure mode `ci.yml:84-87` warns about -- + both bare-`latest` tests would stop covering acceptance criterion 2 with + nothing going red to say so. + """ + match = _LATEST_RE.search(result.stdout) + if not match: + pytest.skip(f"could not resolve `latest` (offline?):\n{result.stdout}\n{result.stderr}") + tag = match.group(1) + if tag not in RELEASES: + pytest.fail( + f"`latest` now resolves to {tag}, which RELEASES " + f"(test_installer_release_layout.py:69) does not carry -- bare-`latest` coverage " + f"has gone stale. Add {tag} to RELEASES with its real published asset list " + f"(`gh release view {tag} --repo alplabai/tan-cli --json assets`)." + ) + + +# --------------------------------------------------------------------------- +# install.ps1 +# --------------------------------------------------------------------------- +@windows_only +@pytest.mark.parametrize("tag", RAW_TAGS) +def test_ps1_installs_the_raw_exe_for_a_pre_archive_tag(release_server, tmp_path, tag): + """#356's repro, Windows half: every tag published so far ships a raw + ``tan-.exe``, and asking for the ``.zip`` 404s.""" + dest = tmp_path / "prog" + result = _install_ps1(release_server, dest, tmp_path, "-Version", tag) + + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + assert (dest / "tan.exe").is_file() + # No launcher and no runtime dir: there is nothing to launch on this layout, + # and a stray tan.cmd beside tan.exe is dead weight PATHEXT never reaches. + assert not (dest / "tan.cmd").exists() + assert not (dest / "tan-cli-lib").exists() + + +@windows_only +def test_ps1_unpacks_the_archive_for_the_first_archive_tag(release_server, tmp_path): + """The #349 layout, tested against the first tag that will actually publish + it -- v0.5.0, not the v0.5.0-rc4 that shipped raw assets.""" + dest = tmp_path / "prog" + result = _install_ps1(release_server, dest, tmp_path, "-Version", FIRST_ARCHIVE_TAG) + + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + assert (dest / "tan.cmd").is_file() + assert (dest / "tan-cli-lib" / "tan.exe").is_file() + assert (dest / "tan-cli-lib" / "_internal").is_dir() + # The archive's top-level `tan/` is RENAMED onto tan-cli-lib, never nested + # inside it. + assert not (dest / "tan-cli-lib" / "tan").exists() + assert not (dest / "tan.exe").exists() + + +@windows_only +def test_ps1_switching_layouts_leaves_no_shadowing_leftovers(release_server, tmp_path): + """Installing a raw tag over an archive install and back again. + + PATHEXT resolves a bare ``tan`` through .EXE before .CMD, so a tan.exe left + beside a fresh tan.cmd would silently keep winning forever -- the reverse of + the pre-#349 case install.ps1 already guarded. Whichever name is not being + installed has to go, and so does the runtime dir it pointed at. + """ + dest = tmp_path / "prog" + assert _install_ps1(release_server, dest, tmp_path, "-Version", FIRST_ARCHIVE_TAG).returncode == 0 + assert (dest / "tan.cmd").is_file() + + result = _install_ps1(release_server, dest, tmp_path, "-Version", "v0.4.1") + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + assert (dest / "tan.exe").is_file() + assert not (dest / "tan.cmd").exists() + assert not (dest / "tan-cli-lib").exists() + + result = _install_ps1(release_server, dest, tmp_path, "-Version", FIRST_ARCHIVE_TAG) + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + assert (dest / "tan.cmd").is_file() + assert (dest / "tan-cli-lib" / "tan.exe").is_file() + assert not (dest / "tan.exe").exists() + + +@windows_only +def test_ps1_bare_latest_installs_whatever_shape_latest_is(release_server, tmp_path): + """The documented one-liner takes no -Version at all. It has to work while + `latest` is still a raw-binary release, and keep working the day it is not.""" + dest = tmp_path / "prog" + result = _install_ps1(release_server, dest, tmp_path) + _skip_unless_latest_is_a_fixture_tag(result) + + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + assert (dest / "tan.exe").is_file() or (dest / "tan.cmd").is_file() + + +@windows_only +def test_ps1_refuses_when_the_checksums_cannot_be_fetched(release_server, tmp_path): + """The fixture has no directory at all for this made-up tag, so + `checksums.txt` 404s -- install.ps1's Outcome 1 (`could not fetch`), not + Outcome 2 (`lists no asset ... under EITHER name`). Renamed from + `test_ps1_refuses_a_release_with_no_asset_for_this_platform`, which is what + this test was called before -- a name Outcome 2 never actually reaches + through this repro, so it pinned the wrong branch under the right-sounding + name (tan-cli#356 adversarial review, item 2). See + `test_ps1_refuses_a_release_with_no_asset_for_this_platform` below for real + Outcome-2 coverage. + """ + dest = tmp_path / "prog" + result = _install_ps1(release_server, dest, tmp_path, "-Version", "v9.9.9-does-not-exist") + + assert result.returncode != 0 + assert not dest.exists() or list(dest.iterdir()) == [] + assert "could not fetch" in (result.stdout + result.stderr) + + +@windows_only +def test_ps1_refuses_a_release_with_no_asset_for_this_platform(release_server, tmp_path): + """The widened Outcome 2 (tan-cli#356), reached for real: `FIRST_ARCHIVE_TAG` + (v0.5.0)'s real published asset list has no Windows arm64 entry under + EITHER name -- no `tan-aarch64-pc-windows-msvc.zip`, no `.exe`. Overriding + `PROCESSOR_ARCHITECTURE` (and clearing `PROCESSOR_ARCHITEW6432`, which + install.ps1 prefers when set) drives the script's own arch detection there + without an actual arm64 Windows runner -- the same mechanism a real one + would use, since install.ps1 never probes hardware directly. + + Reverting install.ps1's whole Outcome-2 block to the pre-#356 single-name + wording -- the defect this test exists to catch -- would leave this test + (and its install.sh sibling) failing, unlike the misnamed test above, + which stayed green either way. + """ + dest = tmp_path / "prog" + result = _install_ps1( + release_server, dest, tmp_path, "-Version", FIRST_ARCHIVE_TAG, + extra_env={"PROCESSOR_ARCHITECTURE": "ARM64", "PROCESSOR_ARCHITEW6432": None}, + ) + + assert result.returncode != 0 + assert not dest.exists() or list(dest.iterdir()) == [] + combined = result.stdout + result.stderr + assert "lists no asset for aarch64-pc-windows-msvc" in combined + assert "tan-aarch64-pc-windows-msvc.zip" in combined + assert "tan-aarch64-pc-windows-msvc.exe" in combined + assert "there is no prebuilt Windows arm64 asset from v0.5.0 onward" in combined + + +# --------------------------------------------------------------------------- +# install.sh +# --------------------------------------------------------------------------- +@posix_only +@pytest.mark.parametrize("tag", RAW_TAGS) +def test_sh_installs_the_raw_binary_for_a_pre_archive_tag(release_server, tmp_path, tag): + """#356's repro, POSIX half -- literally `sh install.sh --version v0.4.1`, + which 404'd on `tan-x86_64-unknown-linux-gnu.tar.gz` before this fix.""" + dest = tmp_path / "bin" + result = _install_sh(release_server, dest, tmp_path, "--version", tag) + + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + installed = dest / "tan" + assert installed.is_file() + # The raw asset IS the program: what landed must be the payload itself, not + # a launcher pointing at a runtime dir that this layout never creates. + assert FIXTURE_VERSION_LINE in installed.read_text(encoding="utf-8") + assert not (dest / "tan-cli-lib").exists() + assert os.access(installed, os.X_OK) + + +@posix_only +def test_sh_unpacks_the_archive_for_the_first_archive_tag(release_server, tmp_path): + dest = tmp_path / "bin" + result = _install_sh(release_server, dest, tmp_path, "--version", FIRST_ARCHIVE_TAG) + + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + launcher = dest / "tan" + assert launcher.is_file() and os.access(launcher, os.X_OK) + assert f'exec "{dest / "tan-cli-lib" / "tan"}"' in launcher.read_text(encoding="utf-8") + payload = dest / "tan-cli-lib" / "tan" + assert payload.is_file() and os.access(payload, os.X_OK) + assert (dest / "tan-cli-lib" / "_internal").is_dir() + # `mv src dst` RENAMES when dst is absent; it does not nest. + assert not (dest / "tan-cli-lib" / "tan" / "tan").exists() + + +@posix_only +def test_sh_switching_layouts_leaves_no_orphaned_runtime(release_server, tmp_path): + """Both layouts install to the same $INSTALL_DIR/tan, so the raw binary + overwrites the launcher on its own -- but tan-cli-lib/ would survive as + ~14 MB of runtime nothing points at.""" + dest = tmp_path / "bin" + assert _install_sh(release_server, dest, tmp_path, "--version", FIRST_ARCHIVE_TAG).returncode == 0 + assert (dest / "tan-cli-lib").is_dir() + + result = _install_sh(release_server, dest, tmp_path, "--version", "v0.4.1") + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + assert FIXTURE_VERSION_LINE in (dest / "tan").read_text(encoding="utf-8") + assert not (dest / "tan-cli-lib").exists() + + +@posix_only +def test_sh_bare_latest_installs_whatever_shape_latest_is(release_server, tmp_path): + dest = tmp_path / "bin" + result = _install_sh(release_server, dest, tmp_path) + _skip_unless_latest_is_a_fixture_tag(result) + + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + assert (dest / "tan").is_file() + + +@posix_only +def test_sh_refuses_when_the_checksums_cannot_be_fetched(release_server, tmp_path): + """checksums.txt is now fetched FIRST and is both the manifest and the + integrity source, so its absence has to refuse before anything downloads -- + and say so as a fetch failure, not as evidence about the release.""" + dest = tmp_path / "bin" + result = _install_sh(release_server, dest, tmp_path, "--version", "v9.9.9-does-not-exist") + + assert result.returncode != 0 + assert not dest.exists() or list(dest.iterdir()) == [] + assert "could not fetch" in (result.stdout + result.stderr) + + +@posix_only +def test_sh_refuses_a_release_with_no_asset_for_this_platform(release_server, tmp_path): + """The widened Outcome 2 (tan-cli#356), reached for real -- unlike + `test_sh_refuses_when_the_checksums_cannot_be_fetched` above, which only + ever reaches the EARLIER `could not fetch checksums.txt` branch. + `v0.5.0-rc4`'s real published asset list (mirrored in `RELEASES`) has no + aarch64-Linux entry under EITHER name. A fake `uname` ahead of the real one + on PATH (`_fake_uname`) drives install.sh's own arch/OS detection there + without an actual aarch64 Linux runner -- the same inputs a real one would + produce, since install.sh never probes hardware beyond `uname`. + + Reverting install.sh's whole Outcome-2 block to the pre-#356 single-name + wording would leave this test (and its install.ps1 sibling) failing. + """ + dest = tmp_path / "bin" + fake_uname_dir = _fake_uname(tmp_path, "Linux", "aarch64") + env_path = f"{fake_uname_dir}{os.pathsep}{os.environ.get('PATH', '')}" + result = _install_sh( + release_server, dest, tmp_path, "--version", "v0.5.0-rc4", + extra_env={"PATH": env_path}, + ) + + assert result.returncode != 0 + assert not dest.exists() or list(dest.iterdir()) == [] + combined = result.stdout + result.stderr + assert "lists no asset for aarch64-unknown-linux-gnu" in combined + assert "tan-aarch64-unknown-linux-gnu.tar.gz" in combined + assert "tan-aarch64-unknown-linux-gnu" in combined + assert "no prebuilt Linux arm64 asset" in combined + + +@posix_only +def test_sh_system_style_install_uses_sudo_when_the_dir_is_not_writable(release_server, tmp_path): + """install.sh:375-393's `as_root`/sudo path -- plus the permission fixes it + guards (`:353 chmod -R a+rX`, `:393 chmod 755`) -- is dead code under every + OTHER test in this file: they all pass `--dir /bin`, which `mkdir -p` + always creates writable, so `as_root` is always the pass-through branch and + real elevation is never exercised (tan-cli#356 adversarial review, item 5). + + This drives the OTHER branch without needing real root: a 0555 (no write + bit) dir the test itself owns forces `[ -w "$INSTALL_DIR" ]` false, and + `_fake_sudo` restores write access legitimately (via ownership, not + elevation) before running the real command -- see its docstring. + """ + dest = tmp_path / "system-style-bin" + dest.mkdir() + dest.chmod(0o555) + calls_log = tmp_path / "sudo-calls.log" + sudo_dir = _fake_sudo(dest, calls_log) + env_path = f"{sudo_dir}{os.pathsep}{os.environ.get('PATH', '')}" + + result = _install_sh( + release_server, dest, tmp_path, "--version", FIRST_ARCHIVE_TAG, + extra_env={"PATH": env_path}, + ) + + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + assert "running sudo" in (result.stdout + result.stderr) + assert calls_log.is_file(), "as_root never shelled out to sudo -- the writable-dir branch ran instead" + logged = calls_log.read_text(encoding="utf-8") + assert "mkdir -p" in logged + assert "chmod 755" in logged + # Archive layout also exercises the tree-wide a+rX chmod (install.sh:353) + # and the elevated mv into place (install.sh:387). + launcher = dest / "tan" + assert launcher.is_file() and os.access(launcher, os.X_OK) + payload = dest / "tan-cli-lib" / "tan" + assert payload.is_file() and os.access(payload, os.X_OK) diff --git a/python/tests/parity/conftest.py b/python/tests/parity/conftest.py new file mode 100644 index 00000000..cc040e24 --- /dev/null +++ b/python/tests/parity/conftest.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Shared fixtures for every test module under ``tests/parity/``. + +Home of the one check that used to live inside ``test_oracle_parity.py`` +alone, opted into by a handful of cases there and inherited by nobody else: +proof that whatever ``target/{release,debug}/tan`` :func:`oracle.rust_binary` +resolved is actually the pinned oracle this whole suite is measured against, +not a stale profile silently standing in for it. + +``test_flash_oracle_parity.py``, ``test_image_size_oracle.py``, +``test_clean_parity.py`` and ``test_run_oracle_parity.py`` each bind +``RUST = rust_binary()`` at import time and gate their live cases on +``missing_for_live`` (an ABSENCE check only) with no version assertion of +their own. Under a live run (``TAN_PARITY_LIVE=1``) against a resolved-but- +wrong binary, every one of those files' failures reads as a wall of PORT +bugs, with nothing naming the oracle itself as the actual problem -- measured: +``TAN_PARITY_LIVE=1 TAN_RUST_BINARY=`` over just +``test_image_size_oracle.py`` + ``test_clean_parity.py`` produced "108 +failed, 8 passed, 9 skipped, 2 xfailed", eight of those passes measured +against the wrong oracle entirely. + +Fixing this per-file (each module opting into its own copy of the check, the +way ``test_oracle_parity.py``'s old ``pinned_oracle`` did) would need to land +in four places every time the pin changes. A session-scoped, autouse fixture +here is inherited by every module in this directory for free, including +``test_oracle_parity.py`` itself, which no longer defines its own copy. +""" +import subprocess + +import pytest + +from .oracle import PINNED_ORACLE_VERSION, rust_binary + +@pytest.fixture(scope="session", autouse=True) +def pinned_oracle() -> None: + """FAIL the session -- never skip -- when a resolved oracle binary is + present but does not report :data:`oracle.PINNED_ORACLE_VERSION`. + + Absence stays a skip: that is what each file's own ``missing_for_live``/ + ``_ORACLE_REQUIRED`` gate already decides, and this fixture defers to it + entirely by doing nothing when ``rust_binary()`` returns ``None``. Only WRONGNESS + fails here -- a resolved binary that answers the wrong ``--version`` -- + because a quiet skip in that case would hide exactly the gap this + harness exists to surface (``missing_for_live``'s own docstring makes the + identical argument for the absence case; tan-cli#272 is where that rule + was first written down, and it applies just as hard to wrongness as to + absence). + + Session-scoped so this runs the check ONCE per test process rather than + once per test case; the resolution cannot change mid-session, so + re-checking it per-test bought nothing but 17 extra ``--version`` + subprocesses a run. + + ``rust_binary()`` is called HERE, in the fixture body, and deliberately + NOT at this conftest's import. It can raise -- a ``TAN_RUST_BINARY`` that + is set but missing, or the mtime TIE between target/{release,debug} that + it now refuses to break silently -- and a raise at conftest import is an + ``ImportError while loading conftest``, which aborts the WHOLE pytest + session rather than this directory. Measured: with a bogus + ``TAN_RUST_BINARY``, ``pytest tests/parity tests/core`` collected zero + tests and exited rc=4, so the repo's own ``python -m pytest tests -q`` + gate would have reported nothing at all. Resolving in the fixture keeps + the blast radius on ``tests/parity``, which is what it is about. + """ + rust = rust_binary() + if rust is None: + return + proc = subprocess.run([rust, "--version"], capture_output=True, text=True, encoding="utf-8") + assert proc.returncode == 0, f"{rust} is not a working tan binary" + stdout = proc.stdout.strip() + assert stdout == PINNED_ORACLE_VERSION, ( + f"resolved oracle {rust!r} reports {stdout!r}, not the pinned " + f"{PINNED_ORACLE_VERSION!r} this whole parity suite is measured " + "against. oracle.rust_binary() picks the MOST RECENTLY BUILT of " + "target/{release,debug}/tan, so a resolved-but-wrong binary means " + "either an inverted or TIED mtime between the two profiles (a tie " + "raises inside rust_binary() itself -- see that function) or an " + "explicit TAN_RUST_BINARY naming the wrong one. Rebuild or remove " + "the stale profile, or set TAN_RUST_BINARY= explicitly." + ) diff --git a/python/tests/parity/oracle.py b/python/tests/parity/oracle.py index 4f066bd4..cf6bf78b 100644 --- a/python/tests/parity/oracle.py +++ b/python/tests/parity/oracle.py @@ -22,9 +22,10 @@ ``VERSION`` Exit code plus the *shape* of the version line. The literals differ BY - DESIGN and permanently: the port declares 0.5.0-dev, the checked-out Rust - declares 0.4.1-dev (``python/tan/version.py`` records why the port must not - reuse the shipped number). What the extension actually contracts on is the + DESIGN and permanently: the port declares ``0.5.0-rc3`` + (``python/tan/version.py``, which records why the port must not reuse the + shipped number), the checked-out Rust declares ``0.4.1`` + (``Cargo.toml``). What the extension actually contracts on is the regex ``/^tan \\d+\\.\\d+\\.\\d+/`` (alp-sdk-vscode/src/alpCli/service.ts:107-121), so that is what is compared -- and a side that fails the regex outright is reported even when both @@ -36,7 +37,8 @@ compare that would enforce it. Prefix-anchoring here silently accepted ``"tan 0.5.0-dev\\nLEAKED EXTRA STDOUT LINE"`` and ``"tan 9.9.9 THIS IS NOT TAN AT ALL"`` as parity -- on the one case that - actually runs today. Rust prints exactly ``tan 0.4.1-dev``. + actually runs today. Rust prints exactly ``tan 0.4.1`` (see + :data:`PINNED_ORACLE_VERSION`, the one place that spelling is owned). ``PLAN`` Exit code, the envelope shell, and -- inside ``data`` -- a NARROWED view of @@ -78,6 +80,7 @@ import json import os import re +import shutil import subprocess import sys from dataclasses import dataclass @@ -202,16 +205,56 @@ class ParityResult: diffs: list[str] +#: The exact ``--version`` line the whole parity suite is measured against. +#: Not ``0.4.1-dev``: that spelling is this repo's OLDER PROSE for the RUST +#: oracle, from before ``Cargo.toml`` settled on ``version = "0.4.1"``, which +#: is why the binary prints no suffix today. It has never been the PORT's +#: string -- ``python/tan/version.py`` declares ``TAN_VERSION = "0.5.0-rc3"``. +#: The checked-out Rust binary's actual stdout, byte for byte, is +#: ``tan 0.4.1`` with no suffix (verified directly: ``tan --version | cat -A`` +#: -> ``tan 0.4.1$``). Shared here, not owned by any one test module, because +#: ``conftest.py``'s session-scoped ``pinned_oracle`` fixture checks against +#: it for every file under ``tests/parity/``, not just the module that used to +#: define it alone. +PINNED_ORACLE_VERSION = "tan 0.4.1" + + def rust_binary() -> str | None: - """``TAN_RUST_BINARY`` if set, else a build in the usual places. Returns - ``None`` only when NOBODY named an oracle and none was built, so the caller - can skip rather than invent a comparison. + """``TAN_RUST_BINARY`` if set, else the MOST RECENTLY BUILT of + ``target/{release,debug}/tan``. Returns ``None`` only when NOBODY named an + oracle and none was built, so the caller can skip rather than invent a + comparison. A set-but-missing ``TAN_RUST_BINARY`` RAISES instead. It must not fall back to some other binary -- an operator who named one should not silently get a different one -- but it must not skip either: a typo'd path in CI would then produce an all-skip, all-green run that certifies nothing, which is the exact failure mode this harness exists to prevent. + + When both profiles exist, the choice is by ``st_mtime``, not a fixed + release-over-debug preference (what this function used to do, + unconditionally). That preference used to silently pick a STALE binary: a + ``target/release/tan`` left over from a much earlier build (measured -- + ``tan 0.1.1``, weeks old) sitting next to a freshly rebuilt + ``target/debug/tan`` (``tan 0.4.1``) always won, because ``release`` was + tried first regardless of either file's age, and every unpinned caller of + this function silently measured against the wrong oracle with no signal + that anything was off. mtime is what an ordinary edit-build-test loop + actually implies: whichever profile was rebuilt LAST is the one a + developer (or CI job that just ran ``cargo build``) intends to test + against right now. A caller that legitimately wants one profile over the + other regardless of freshness should set ``TAN_RUST_BINARY`` explicitly + rather than rely on this default. + + A TIE (identical ``st_mtime`` on both profiles) is refused outright rather + than resolved silently -- silently is what this function used to do, and + it always broke to ``target/release``, which is exactly the stale-pick bug + the mtime rule above replaced. A CI cache restore, ``cp -p``, rsync, or an + artifact download can equalise (or invert) mtimes on ``target/`` with no + rebuild involved, so a tie is a real, reachable way to reinstate that bug + for every caller that does not separately assert the resolved version (see + ``conftest.py``'s ``pinned_oracle``, which is that safety net -- this raise + is the thing it nets). """ override = os.environ.get("TAN_RUST_BINARY") if override: @@ -222,11 +265,25 @@ def rust_binary() -> str | None: "Fix the path, or unset it to fall back to target/{release,debug}." ) return override - for profile in ("release", "debug"): - candidate = REPO_ROOT / "target" / profile / f"tan{_EXE}" - if candidate.exists(): - return str(candidate) - return None + candidates = [ + REPO_ROOT / "target" / profile / f"tan{_EXE}" for profile in ("release", "debug") + ] + existing = [c for c in candidates if c.exists()] + if not existing: + return None + newest_mtime = max(c.stat().st_mtime for c in existing) + tied = [c for c in existing if c.stat().st_mtime == newest_mtime] + if len(tied) > 1: + raise RuntimeError( + "target/release/tan and target/debug/tan report the IDENTICAL " + f"mtime ({newest_mtime!r}): {', '.join(str(c) for c in tied)}. " + "Refusing to pick one silently -- a tie used to break to " + "target/release unconditionally, which is the exact stale-binary " + "bug the mtime rule this function now uses was written to " + "replace. Rebuild one of the two profiles to break the tie, or " + "set TAN_RUST_BINARY= to the one you mean." + ) + return str(tied[0]) def missing_for_live(rust: str | None) -> bool: @@ -243,6 +300,86 @@ def missing_for_live(rust: str | None) -> bool: return oracle_fixtures.LIVE and rust is None +def empty_tool_inventory(scratch: Path) -> str: + """A ``PATH`` value that resolves NOTHING except ``which`` itself -- the + general form of ``test_flash_oracle_parity.py``'s ``_pin_tool_inventory`` + for a case whose frozen fixture answer is "no such tool anywhere on PATH", + not "found this specific stand-in". Any ``shutil.which``/ + ``doctor_cmd.on_path`` probe run against this directory reports every + PROBED name absent -- ``west`` for ``test_west_forward_matches_rust`` + today, ``git``/``cmake``/``ninja``/``python3``/``xz``/``wget`` for + ``support-bundle.hostPrerequisites``, and any other which()-gated case's + absent-tool branch tomorrow -- matching whatever tool inventory a + fixture's capture host happened to lack (tan-cli#313, tan-cli#324), + without inventing a second bespoke per-file helper for each one that + comes up. + + Seeds the directory with exactly one file: a symlink to the REAL + ``which`` this host resolves on its own PATH, POSIX only (a no-op on + Windows, whose probe -- ``crate::util::windows_path_lookup`` -- walks + ``%PATH%`` by hand and never spawns an external ``which`` at all). A + directory that is genuinely, literally empty is NOT a clean "nothing on + PATH" answer on POSIX: the oracle's own probe + (``crates/tan-cli/src/util.rs:35-50``, ``command_on_path``) resolves a + tool by SPAWNING ``which `` as a subprocess, and that spawn itself + has to find ``which`` via this SAME (oracle-controlled) PATH. An empty + directory can't resolve ``which`` either, so the probe fails before it + ever answers the question asked -- measured directly: a PATH holding + every one of ``support-bundle``'s six required tools but NOT ``which`` + still reports all six missing, and copying a working ``which`` in + (touching nothing else) makes that same warning vanish entirely. A + literally-empty directory's "everything missing" answer was an artefact + of the unresolvable ``which`` spawn, not a real absence measurement -- + see ``_DEFERRED_VERBS``'s own comment for what the pinned + ``support-bundle`` answer means now that this seeds a working ``which``: + a GENUINE probe that ran and found nothing, not a degenerate one that + could not run at all. + + REPLACES ``PATH`` outright rather than prepending, for the identical + reason ``_pin_tool_inventory`` does: prepending would still let a REAL + tool further down the replay host's own PATH be found, which is exactly + the host-dependence this exists to remove. + + Hand the result to :func:`compare`'s ``python_env_overrides`` -- e.g. + ``{"PATH": empty_tool_inventory(tmp_path)}`` -- for a FROZEN-REPLAY + caller, and never fabricate the equivalent for the rust side THERE: the + frozen answer is a recorded fixture, and pinning only the python side's + PATH is exactly right in that mode (see that parameter's own docstring + for why forwarding the same pin to the rust side under + ``TAN_PARITY_LIVE=1`` is not a safe substitute -- the whole tan-cli#313/ + #324 bug was one side pinned and the other left to whatever happened to + be installed). That ban is scoped to ``compare()``'s frozen-replay path -- + it does not reach a case that spawns BOTH binaries live on every run + instead (e.g. ``test_deferred_verb_is_a_known_divergence_from_the_ + oracle``): there, applying this SAME value to both sides' subprocess + environments is sound, because it is one identical override applied + twice, symmetrically, not a pin smuggled onto one side only. + """ + stub_dir = scratch / "empty-path" + stub_dir.mkdir(exist_ok=True) + if sys.platform != "win32": + # The seed is NOT optional. A literally-empty PATH makes the oracle's + # POSIX probe unable to resolve `which` ITSELF, so its tool report goes + # degenerate: it names every tool missing because it could not look, + # not because they are absent. A caller pinning that answer measures a + # broken probe. Refuse rather than silently return to it (tan-cli#313, + # tan-cli#324 -- the same silent-degradation class both closed). + real_which = shutil.which("which") + if real_which is None: + raise RuntimeError( + "empty_tool_inventory() cannot seed `which` into the stub PATH: " + "shutil.which('which') returned None on this POSIX host. Without " + "it the oracle's tool probe cannot run at all and reports every " + "tool missing for the wrong reason -- refusing rather than " + "pinning that artefact." + ) + link = stub_dir / "which" + if not link.exists(): + os.symlink(real_which, link) + assert link.exists(), f"failed to seed `which` into {stub_dir}" + return str(stub_dir) + + def python_command() -> list[str]: """The port under test. Defaults to the source tree so the harness runs without a packaging step; ``TAN_PYTHON_BINARY`` points it at the PyInstaller @@ -270,7 +407,22 @@ def _env(home: Path) -> dict[str, str]: } -def _run(command: list[str], argv: list[str], cwd: Path, home: Path): +def _run( + command: list[str], + argv: list[str], + cwd: Path, + home: Path, + *, + env_overrides: dict[str, str] | None = None, +): + """``env_overrides`` layers on top of :func:`_env`'s shared environment -- + e.g. pinning ``PATH`` so a tool-presence probe INSIDE the spawned process + (``shutil.which``/``doctor_cmd.on_path``) cannot pick up whatever happens + to be installed on whichever host is replaying this suite (tan-cli#313). + Empty by default, so every existing caller is unaffected.""" + env = _env(home) + if env_overrides: + env = {**env, **env_overrides} proc = subprocess.run( [*command, *argv], capture_output=True, @@ -281,7 +433,7 @@ def _run(command: list[str], argv: list[str], cwd: Path, home: Path): encoding="utf-8", errors="replace", cwd=cwd, - env=_env(home), + env=env, ) try: payload = json.loads(proc.stdout) @@ -355,6 +507,7 @@ def compare( home: Path | None = None, python: list[str] | None = None, extra_scrub_roots: tuple[Path | str, ...] = (), + python_env_overrides: dict[str, str] | None = None, ) -> ParityResult: """Diff the two binaries on ``argv``, scoped to ``surface``. @@ -374,12 +527,57 @@ def compare( cover -- e.g. a checked-in fixture file (a ``--plan-from`` plan) that itself embeds the path of whatever alp-sdk checkout it was captured against. Empty by default, so every existing caller is unaffected. + + ``python_env_overrides`` (tan-cli#313) layers extra environment onto the + PYTHON side's subprocess ONLY, never the rust side's -- and is REFUSED + outright whenever ``oracle_fixtures.LIVE`` is set, rather than silently + applied to one side only. In frozen replay (the default, no + ``TAN_PARITY_LIVE``) the rust side never spawns anything at all, so + pinning only the python side is exactly right: the frozen fixture IS the + rust answer, already captured against a specific tool inventory, and the + python side's live PATH probe needs pinning to match it, or the + comparison depends on what happens to be installed on whoever runs the + suite. See ``test_flash_oracle_parity.py``'s ``_pin_tool_inventory``. + + Under ``TAN_PARITY_LIVE=1`` both sides DO spawn, and ``_env``'s own + invariant applies in full ("one environment, shared by both sides -- + the whole point is that the only difference between the two runs is the + implementation"): applying the pin to python alone would silently break + that invariant for exactly the pinned cases, and forwarding it to + ``rust_run`` too is NOT a safe substitute -- measured against the real + oracle (``target/debug/tan``), POSIX ``command_on_path`` + (``crates/tan-cli/src/util.rs:45-50``) resolves a tool by SPAWNING + ``which`` as its own subprocess, which itself has to be found via the + (now-pinned, ``which``-free) ``PATH`` -- so a PATH replaced with only a + ``dd`` stand-in makes ``which`` itself unresolvable, every rust-side + probe report "not found" including ``dd``, and the SAME pin that makes + python answer "dd" makes rust answer "bmaptool" -- the opposite tool, on + the identical override. So under ``TAN_PARITY_LIVE=1`` this parameter + raises rather than comparing two binaries under two different (or two + subtly-broken-in-opposite-directions) environments; the caller should + drop the pin for a live run (both binaries then share this host's REAL + tool inventory, which is the whole point of a live re-validation) and + keep it only for frozen replay. ``None`` by default, so every existing + caller is unaffected. """ home = home or cwd roots = (cwd, home, *extra_scrub_roots) + if oracle_fixtures.LIVE and python_env_overrides: + raise RuntimeError( + "compare() got python_env_overrides under TAN_PARITY_LIVE=1: both " + "binaries spawn for real in this mode, and pinning only the " + "python side's PATH breaks _env's 'one environment, shared by " + "both sides' invariant -- see this parameter's own docstring for " + "why forwarding the same pin to the rust side is not a safe fix " + "either (tan-cli#313). Drop python_env_overrides for a live run, " + "or drop TAN_PARITY_LIVE to replay the frozen fixture with it." + ) + r_code, r_out = rust_run(argv, cwd, home, scrub_roots=roots) - p_code, p_out = _run(python or python_command(), argv, cwd, home) + p_code, p_out = _run( + python or python_command(), argv, cwd, home, env_overrides=python_env_overrides + ) p_out = oracle_fixtures.scrub(p_out, *roots) # Scoped to PATH_KEYS, on BOTH sides, before the diff -- see that # constant's own docstring. A no-op whenever the two sides already agree diff --git a/python/tests/parity/oracle_fixtures/PROVENANCE.txt b/python/tests/parity/oracle_fixtures/PROVENANCE.txt index 6a241550..41133008 100644 --- a/python/tests/parity/oracle_fixtures/PROVENANCE.txt +++ b/python/tests/parity/oracle_fixtures/PROVENANCE.txt @@ -162,3 +162,44 @@ returns nothing, `tests/gates/test_no_leaked_host_paths.py` passes with every file in this directory (and this file's own new `test_oracle_fixtures.py`) tracked, and the other four fixture files in this directory are byte- identical to before this pass (`git diff --stat` on each is empty). + +-------------------------------------------------------------------------- + +THE CAPTURE HOST'S TOOL INVENTORY IS PART OF THE FROZEN ANSWER +(tan-cli#313, tan-cli#324) + +Eight cases branch on a `which()` probe, so their frozen answers encode which +tools the CAPTURE host had -- not just what the oracle computes. The capture +host had: + + west ABSENT -- the three west_forward cases froze the + "west not found on PATH" launch error + dd PRESENT -- the yocto_wic cases froze "would run dd if=..." + bmaptool ABSENT -- else those cases would have frozen a bmaptool argv + +Replay pins the PYTHON side's PATH to match that inventory, so the comparison +does not silently measure the replay host instead of the port. The pins: + + test_flash_oracle_parity.py `_TOOL_PROBE_PINNED_CASES` (5 cases) -> + `_pin_tool_inventory`, a scratch PATH holding a stand-in `dd` only + test_oracle_parity.py `test_west_forward_matches_rust[migrate|lock|quality]` + (3 cases) -> `oracle.empty_tool_inventory`, a scratch PATH holding nothing + +CONSEQUENCE FOR RE-CAPTURE, and the reason this section exists: the recipe at +the top of this file NO LONGER WORKS AS WRITTEN for these eight. Under +TAN_PARITY_LIVE=1 both sides genuinely spawn, so `compare()` REFUSES a +`python_env_overrides` rather than compare one pinned side against one +unpinned side -- deliberately, since that would be a fresh divergence rather +than a capture. Measured: + + TAN_PARITY_LIVE=1 python -m pytest \ + "tests/parity/test_oracle_parity.py::test_west_forward_matches_rust" + -> 3 failed, RuntimeError: compare() got python_env_overrides under + TAN_PARITY_LIVE=1 + +To re-capture any of the eight, run the capture on a host whose tool +inventory MATCHES the table above (no `west`, no `bmaptool`, a real `dd`) and +drop the pin for that run. Capturing on a host with `west` installed and the +pin dropped would freeze a different answer entirely -- one that then only +replays on hosts that also have `west`, reintroducing exactly the bug +tan-cli#324 closed. diff --git a/python/tests/parity/oracle_fixtures/test_clean_parity.json b/python/tests/parity/oracle_fixtures/test_clean_parity.json index 7df4aebe..44358da9 100644 --- a/python/tests/parity/oracle_fixtures/test_clean_parity.json +++ b/python/tests/parity/oracle_fixtures/test_clean_parity.json @@ -1,2442 +1,2442 @@ -{ - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-is-file]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "file", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-is-junction]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-device-ns]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "\\\\?\\C:\\nope", - "dryRun": false, - "removed": 1, - "targets": [ - { - "action": "absent", - "kind": "absent", - "path": "\\\\?\\C:\\nope" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-dot]#0": [ - 1, - { - "command": "clean", - "data": { - "buildRoot": "\\proj", - "dryRun": false, - "removed": 0, - "targets": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "clean.unsafe-build-root", - "message": "refusing to remove `\\proj`: a build root may not be the project root, an ancestor of it, or a filesystem root", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-dotdot-text]#0": [ - 1, - { - "__raw__": "" - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-dotdot]#0": [ - 1, - { - "command": "clean", - "data": { - "buildRoot": "", - "dryRun": false, - "removed": 0, - "targets": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "clean.unsafe-build-root", - "message": "refusing to remove ``: a build root may not be the project root, an ancestor of it, or a filesystem root", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-drive-relative]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "C:foo", - "dryRun": false, - "removed": 1, - "targets": [ - { - "action": "absent", - "kind": "absent", - "path": "C:foo" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-empty]#0": [ - 1, - { - "command": "clean", - "data": { - "buildRoot": "\\proj", - "dryRun": false, - "removed": 0, - "targets": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "clean.unsafe-build-root", - "message": "refusing to remove `\\proj`: a build root may not be the project root, an ancestor of it, or a filesystem root", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-nested]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "\\proj\\build\\m55_hp-zephyr", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "\\proj\\build\\m55_hp-zephyr" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/build", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-outside]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "\\oot", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "\\oot" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-rooted]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "C:\\rooted-nope", - "dryRun": false, - "removed": 1, - "targets": [ - { - "action": "absent", - "kind": "absent", - "path": "C:\\rooted-nope" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-unc]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "\\\\server\\share\\x", - "dryRun": false, - "removed": 1, - "targets": [ - { - "action": "absent", - "kind": "absent", - "path": "\\\\server\\share\\x" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[crossed-device-ns-flag-and-out-of-tree-slice]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "\\\\?\\C:\\nope", - "dryRun": false, - "removed": 1, - "targets": [ - { - "action": "absent", - "kind": "absent", - "path": "\\\\?\\C:\\nope" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "oot", - "oot/tmp.txt", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/build/system-manifest.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[dry-run-text]#0": [ - 0, - { - "__raw__": "" - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[dry-run]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": true, - "removed": 0, - "targets": [ - { - "action": "would-remove", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "would-remove", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[junction-inside-build]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-comment-only]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [ - { - "code": "clean.manifest-unreadable", - "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: missing field `schema_version`", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-device-ns-build-dir]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - }, - { - "action": "absent", - "kind": "absent", - "path": "\\\\?\\C:\\x" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-dotdot-build-dir]#0": [ - 1, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "refused-unsafe", - "kind": "dir", - "path": "/proj\\../.." - }, - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 1, - "issues": [ - { - "code": "clean.unsafe-target", - "message": "refusing to remove slice 'm55_hp' build_dir \"../..\" (resolves to /proj\\../..) \u2014 it is the project root, an ancestor of it, or a filesystem root; fix build/system-manifest.yaml", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-drive-relative-build-dir]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - }, - { - "action": "absent", - "kind": "absent", - "path": "C:rel" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-empty-build-dir]#0": [ - 1, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "refused-unsafe", - "kind": "dir", - "path": "/proj\\" - }, - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 1, - "issues": [ - { - "code": "clean.unsafe-target", - "message": "refusing to remove slice 'm55_hp' build_dir \"\" (resolves to /proj\\) \u2014 it is the project root, an ancestor of it, or a filesystem root; fix build/system-manifest.yaml", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-empty-file]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [ - { - "code": "clean.manifest-unreadable", - "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: missing field `schema_version`", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-is-a-directory]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-no-build-dir]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-non-utf8]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-null-doc]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [ - { - "code": "clean.manifest-unreadable", - "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: invalid type: unit value, expected struct SystemManifest", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-out-of-tree-dry]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": true, - "removed": 0, - "targets": [ - { - "action": "would-remove", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "would-remove", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - }, - { - "action": "would-remove", - "kind": "dir", - "path": "/proj\\../oot" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "oot", - "oot/tmp.txt", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/build/system-manifest.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-out-of-tree]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 3, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - }, - { - "action": "removed", - "kind": "dir", - "path": "/proj\\../oot" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-quiet-text]#0": [ - 0, - { - "__raw__": "" - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-quiet]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [ - { - "code": "clean.manifest-unreadable", - "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: invalid type: sequence, expected struct SystemManifest", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-root-build-dir]#0": [ - 1, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "refused-unsafe", - "kind": "dir", - "path": "C:/" - }, - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 1, - "issues": [ - { - "code": "clean.unsafe-target", - "message": "refusing to remove slice 'm55_hp' build_dir \"/\" (resolves to C:/) \u2014 it is the project root, an ancestor of it, or a filesystem root; fix build/system-manifest.yaml", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-rooted-build-dir]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - }, - { - "action": "absent", - "kind": "absent", - "path": "C:\\rooted-nope" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-scalar-core-id]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-sequence]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [ - { - "code": "clean.manifest-unreadable", - "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: invalid type: sequence, expected struct SystemManifest", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-slice-missing-core-id]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [ - { - "code": "clean.manifest-unreadable", - "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: slices[0]: missing field `core_id`", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-slices-scalar]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [ - { - "code": "clean.manifest-unreadable", - "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: slices: invalid type: integer `7`, expected a sequence", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-subsumed]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-unc-build-dir]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - }, - { - "action": "absent", - "kind": "absent", - "path": "\\\\srv\\sh\\x" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-v2]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [ - { - "code": "clean.manifest-unreadable", - "message": "ignoring unreadable system-manifest.yaml: unsupported system-manifest schema_version 2 (this CLI consumes v1); upgrade the CLI or the SDK so the versions match", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-version-string]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [ - { - "code": "clean.manifest-unreadable", - "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: schema_version: invalid type: string \"1\", expected u32", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[no-sdk]#0": [ - 1, - { - "command": "clean", - "data": { - "buildRoot": "", - "dryRun": false, - "removed": 0, - "targets": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "clean.sdk-root-not-found", - "message": "Cannot locate alp-sdk root.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[nothing-to-remove]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "\\proj\\absent", - "dryRun": false, - "removed": 1, - "targets": [ - { - "action": "absent", - "kind": "absent", - "path": "\\proj\\absent" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[positional-absolute]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/oot\\build", - "dryRun": false, - "removed": 0, - "targets": [ - { - "action": "absent", - "kind": "absent", - "path": "/oot\\build" - }, - { - "action": "absent", - "kind": "absent", - "path": "/oot\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "oot", - "oot/tmp.txt", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[positional-dotdot]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "\\proj\\..\\build", - "dryRun": false, - "removed": 0, - "targets": [ - { - "action": "absent", - "kind": "absent", - "path": "\\proj\\..\\build" - }, - { - "action": "absent", - "kind": "absent", - "path": "\\proj\\..\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[read-only-artefact]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[real-text]#0": [ - 0, - { - "__raw__": "" - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[real]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[sdk-root-bogus]#0": [ - 1, - { - "command": "clean", - "data": { - "buildRoot": "", - "dryRun": false, - "removed": 0, - "targets": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "clean.sdk-root-not-found", - "message": "Cannot locate alp-sdk root.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[sdk-root-flag]#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": true, - "removed": 0, - "targets": [ - { - "action": "would-remove", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "would-remove", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "sdkRootFlag" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_the_comparator_goes_red_on_a_planted_divergence#0": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": true, - "removed": 0, - "targets": [ - { - "action": "would-remove", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "would-remove", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/.alp-build-state.json", - "proj/board.yaml", - "proj/build", - "proj/build/m55_hp-zephyr", - "proj/build/m55_hp-zephyr/zephyr", - "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", - "proj/build/marker", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ], - "tests/parity/test_clean_parity.py::test_the_comparator_goes_red_on_a_planted_divergence#1": [ - 0, - { - "command": "clean", - "data": { - "buildRoot": "/proj\\build", - "dryRun": false, - "removed": 2, - "targets": [ - { - "action": "removed", - "kind": "dir", - "path": "/proj\\build" - }, - { - "action": "removed", - "kind": "file", - "path": "/proj\\.alp-build-state.json" - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "/proj/board.yaml", - "root": "/proj" - }, - "sdk": { - "root": "/proj", - "sourceTier": "discovery" - } - }, - [ - "CANARY.txt", - "home", - "precious", - "precious/work.txt", - "proj", - "proj/board.yaml", - "proj/scripts", - "proj/scripts/alp_project.py", - "proj/src", - "proj/src/main.c" - ] - ] -} +{ + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-is-file]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "file", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-is-junction]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-device-ns]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "\\\\?\\C:\\nope", + "dryRun": false, + "removed": 1, + "targets": [ + { + "action": "absent", + "kind": "absent", + "path": "\\\\?\\C:\\nope" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-dot]#0": [ + 1, + { + "command": "clean", + "data": { + "buildRoot": "\\proj", + "dryRun": false, + "removed": 0, + "targets": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "clean.unsafe-build-root", + "message": "refusing to remove `\\proj`: a build root may not be the project root, an ancestor of it, or a filesystem root", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-dotdot-text]#0": [ + 1, + { + "__raw__": "" + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-dotdot]#0": [ + 1, + { + "command": "clean", + "data": { + "buildRoot": "", + "dryRun": false, + "removed": 0, + "targets": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "clean.unsafe-build-root", + "message": "refusing to remove ``: a build root may not be the project root, an ancestor of it, or a filesystem root", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-drive-relative]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "C:foo", + "dryRun": false, + "removed": 1, + "targets": [ + { + "action": "absent", + "kind": "absent", + "path": "C:foo" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-empty]#0": [ + 1, + { + "command": "clean", + "data": { + "buildRoot": "\\proj", + "dryRun": false, + "removed": 0, + "targets": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "clean.unsafe-build-root", + "message": "refusing to remove `\\proj`: a build root may not be the project root, an ancestor of it, or a filesystem root", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-nested]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "\\proj\\build\\m55_hp-zephyr", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "\\proj\\build\\m55_hp-zephyr" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/build", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-outside]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "\\oot", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "\\oot" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-rooted]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "C:\\rooted-nope", + "dryRun": false, + "removed": 1, + "targets": [ + { + "action": "absent", + "kind": "absent", + "path": "C:\\rooted-nope" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[build-root-unc]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "\\\\server\\share\\x", + "dryRun": false, + "removed": 1, + "targets": [ + { + "action": "absent", + "kind": "absent", + "path": "\\\\server\\share\\x" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[crossed-device-ns-flag-and-out-of-tree-slice]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "\\\\?\\C:\\nope", + "dryRun": false, + "removed": 1, + "targets": [ + { + "action": "absent", + "kind": "absent", + "path": "\\\\?\\C:\\nope" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "oot", + "oot/tmp.txt", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/build/system-manifest.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[dry-run-text]#0": [ + 0, + { + "__raw__": "" + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[dry-run]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": true, + "removed": 0, + "targets": [ + { + "action": "would-remove", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "would-remove", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[junction-inside-build]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-comment-only]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [ + { + "code": "clean.manifest-unreadable", + "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: missing field `schema_version`", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-device-ns-build-dir]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + }, + { + "action": "absent", + "kind": "absent", + "path": "\\\\?\\C:\\x" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-dotdot-build-dir]#0": [ + 1, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "refused-unsafe", + "kind": "dir", + "path": "/proj\\../.." + }, + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 1, + "issues": [ + { + "code": "clean.unsafe-target", + "message": "refusing to remove slice 'm55_hp' build_dir \"../..\" (resolves to /proj\\../..) \u2014 it is the project root, an ancestor of it, or a filesystem root; fix build/system-manifest.yaml", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-drive-relative-build-dir]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + }, + { + "action": "absent", + "kind": "absent", + "path": "C:rel" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-empty-build-dir]#0": [ + 1, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "refused-unsafe", + "kind": "dir", + "path": "/proj\\" + }, + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 1, + "issues": [ + { + "code": "clean.unsafe-target", + "message": "refusing to remove slice 'm55_hp' build_dir \"\" (resolves to /proj\\) \u2014 it is the project root, an ancestor of it, or a filesystem root; fix build/system-manifest.yaml", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-empty-file]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [ + { + "code": "clean.manifest-unreadable", + "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: missing field `schema_version`", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-is-a-directory]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-no-build-dir]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-non-utf8]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-null-doc]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [ + { + "code": "clean.manifest-unreadable", + "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: invalid type: unit value, expected struct SystemManifest", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-out-of-tree-dry]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": true, + "removed": 0, + "targets": [ + { + "action": "would-remove", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "would-remove", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + }, + { + "action": "would-remove", + "kind": "dir", + "path": "/proj\\../oot" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "oot", + "oot/tmp.txt", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/build/system-manifest.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-out-of-tree]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 3, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + }, + { + "action": "removed", + "kind": "dir", + "path": "/proj\\../oot" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-quiet-text]#0": [ + 0, + { + "__raw__": "" + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-quiet]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [ + { + "code": "clean.manifest-unreadable", + "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: invalid type: sequence, expected struct SystemManifest", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-root-build-dir]#0": [ + 1, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "refused-unsafe", + "kind": "dir", + "path": "C:/" + }, + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 1, + "issues": [ + { + "code": "clean.unsafe-target", + "message": "refusing to remove slice 'm55_hp' build_dir \"/\" (resolves to C:/) \u2014 it is the project root, an ancestor of it, or a filesystem root; fix build/system-manifest.yaml", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-rooted-build-dir]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + }, + { + "action": "absent", + "kind": "absent", + "path": "C:\\rooted-nope" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-scalar-core-id]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-sequence]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [ + { + "code": "clean.manifest-unreadable", + "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: invalid type: sequence, expected struct SystemManifest", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-slice-missing-core-id]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [ + { + "code": "clean.manifest-unreadable", + "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: slices[0]: missing field `core_id`", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-slices-scalar]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [ + { + "code": "clean.manifest-unreadable", + "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: slices: invalid type: integer `7`, expected a sequence", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-subsumed]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-unc-build-dir]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + }, + { + "action": "absent", + "kind": "absent", + "path": "\\\\srv\\sh\\x" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-v2]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [ + { + "code": "clean.manifest-unreadable", + "message": "ignoring unreadable system-manifest.yaml: unsupported system-manifest schema_version 2 (this CLI consumes v1); upgrade the CLI or the SDK so the versions match", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[manifest-version-string]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [ + { + "code": "clean.manifest-unreadable", + "message": "ignoring unreadable system-manifest.yaml: system-manifest is not valid YAML: schema_version: invalid type: string \"1\", expected u32", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[no-sdk]#0": [ + 1, + { + "command": "clean", + "data": { + "buildRoot": "", + "dryRun": false, + "removed": 0, + "targets": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "clean.sdk-root-not-found", + "message": "Cannot locate alp-sdk root.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[nothing-to-remove]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "\\proj\\absent", + "dryRun": false, + "removed": 1, + "targets": [ + { + "action": "absent", + "kind": "absent", + "path": "\\proj\\absent" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[positional-absolute]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/oot\\build", + "dryRun": false, + "removed": 0, + "targets": [ + { + "action": "absent", + "kind": "absent", + "path": "/oot\\build" + }, + { + "action": "absent", + "kind": "absent", + "path": "/oot\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "oot", + "oot/tmp.txt", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[positional-dotdot]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "\\proj\\..\\build", + "dryRun": false, + "removed": 0, + "targets": [ + { + "action": "absent", + "kind": "absent", + "path": "\\proj\\..\\build" + }, + { + "action": "absent", + "kind": "absent", + "path": "\\proj\\..\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[read-only-artefact]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[real-text]#0": [ + 0, + { + "__raw__": "" + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[real]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[sdk-root-bogus]#0": [ + 1, + { + "command": "clean", + "data": { + "buildRoot": "", + "dryRun": false, + "removed": 0, + "targets": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "clean.sdk-root-not-found", + "message": "Cannot locate alp-sdk root.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_python_clean_matches_rust[sdk-root-flag]#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": true, + "removed": 0, + "targets": [ + { + "action": "would-remove", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "would-remove", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "sdkRootFlag" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_the_comparator_goes_red_on_a_planted_divergence#0": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": true, + "removed": 0, + "targets": [ + { + "action": "would-remove", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "would-remove", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/.alp-build-state.json", + "proj/board.yaml", + "proj/build", + "proj/build/m55_hp-zephyr", + "proj/build/m55_hp-zephyr/zephyr", + "proj/build/m55_hp-zephyr/zephyr/zephyr.elf", + "proj/build/marker", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ], + "tests/parity/test_clean_parity.py::test_the_comparator_goes_red_on_a_planted_divergence#1": [ + 0, + { + "command": "clean", + "data": { + "buildRoot": "/proj\\build", + "dryRun": false, + "removed": 2, + "targets": [ + { + "action": "removed", + "kind": "dir", + "path": "/proj\\build" + }, + { + "action": "removed", + "kind": "file", + "path": "/proj\\.alp-build-state.json" + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "/proj/board.yaml", + "root": "/proj" + }, + "sdk": { + "root": "/proj", + "sourceTier": "discovery" + } + }, + [ + "CANARY.txt", + "home", + "precious", + "precious/work.txt", + "proj", + "proj/board.yaml", + "proj/scripts", + "proj/scripts/alp_project.py", + "proj/src", + "proj/src/main.c" + ] + ] +} diff --git a/python/tests/parity/oracle_fixtures/test_flash_oracle_parity.json b/python/tests/parity/oracle_fixtures/test_flash_oracle_parity.json index 19a59794..9b1f5d6f 100644 --- a/python/tests/parity/oracle_fixtures/test_flash_oracle_parity.json +++ b/python/tests/parity/oracle_fixtures/test_flash_oracle_parity.json @@ -1,2023 +1,2023 @@ -{ - "tests/parity/test_flash_oracle_parity.py::test_a_real_spawn_diffs_including_the_captured_failure_tail#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "yocto_wic[c1]: dd: failed to open '\\.\\build\\a.elf': No such file or directory", - "method": "yocto_wic", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "yocto_wic[c1]: dd: failed to open '\\.\\build\\a.elf': No such file or directory", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[absolute-artefact-passes-through]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "a", - "kind": "slice", - "message": "would run west flash --build-dir C:/abs/tree", - "method": "zephyr_west_flash", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[bare-int-base-round-trips-to-hex]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[boot-order-order-and-both-warnings]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "a", - "kind": "slice", - "message": "would run west flash --build-dir \\.\\build", - "method": "zephyr_west_flash", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.boot-order-unknown-core", - "message": "flash: slice 'b' has no boot_order entry; not flashed", - "severity": "warning" - }, - { - "code": "flash.boot-order-unknown-core", - "message": "flash: boot_order references core 'ghost' not in slices; skipping", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[cmake-jobs-and-config]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run cmake --build \\.\\build --target prog --config Rel -j 4", - "method": "baremetal_cmake_flash", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[core-and-helper-together-select-nothing]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.nothing-matched", - "message": "flash: nothing matched the requested filters.", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[core-filter-matches-nothing]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.nothing-matched", - "message": "flash: nothing matched the requested filters.", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[core-filter-suppresses-the-missing-boot-order-warning]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "a", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[empty-boot-order-sorts-and-helpers-last]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "a55_cluster", - "kind": "slice", - "message": "would run dd if=\\.\\build\\a.wic of=/dev/sdb bs=4M conv=fsync status=progress", - "method": "yocto_wic", - "rc": 0, - "status": "ok" - }, - { - "id": "m33_sm", - "kind": "slice", - "message": "would run west flash --build-dir \\.\\build", - "method": "zephyr_west_flash", - "rc": 0, - "status": "ok" - }, - { - "id": "gd32_bridge", - "kind": "helper", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[empty-core-id-is-dropped]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.nothing-matched", - "message": "flash: nothing matched the requested filters.", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[explicit-build-dir-wins-over-the-derived-one]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run west flash --build-dir /elsewhere/bd --hex-file /h/x.hex", - "method": "zephyr_west_flash", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[explicit-relative-build-root]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[explicit-zero-speed-means-default]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[flash-args-tbd-bare-string]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "h1", - "kind": "helper", - "message": "flash: helper 'h1' has an unresolved 'TBD' flash_arg (e.g. mode/device not finalised); skipping", - "method": "swd_probe", - "rc": -1, - "status": "skipped" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.nothing-matched", - "message": "flash: nothing matched the requested filters.", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[flash-args-tbd-mapping]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "h1", - "kind": "helper", - "message": "flash: helper 'h1' has an unresolved 'TBD' flash_arg (e.g. mode/device not finalised); skipping", - "method": "swd_probe", - "rc": -1, - "status": "skipped" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.nothing-matched", - "message": "flash: nothing matched the requested filters.", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[helper-filter-suppresses-slices-entirely]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "h1", - "kind": "helper", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[helper-no-flash-method]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "h1", - "kind": "helper", - "message": "flash: helper 'h1' has no flash_method; skipping", - "rc": -1, - "status": "skipped" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.nothing-matched", - "message": "flash: nothing matched the requested filters.", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[helper-update-channel-is-not-a-flash-target]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "cc3501e_otp", - "kind": "helper", - "message": "flash: helper 'cc3501e_otp' is Alp-OTA-updated (update_channel: alp_ota_spi_otp), not a customer flash target; skipping", - "rc": -1, - "status": "skipped" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.nothing-matched", - "message": "flash: nothing matched the requested filters.", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[jlink-bin-artefact-uses-loadbin]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run JLinkExe -device NRF_DUMMY -if SWD -speed 1000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[malformed-boot-order-steps-are-dropped]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "a", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[manifest-missing]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.manifest-not-found", - "message": "system-manifest.yaml not found at \\.\\build\\system-manifest.yaml; run `tan build --project .` first.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[missing-tool-fails]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "flash: slice 'c1' backend 'zephyr_west_flash' needs one of [\"west\"] on PATH; none found.", - "method": "zephyr_west_flash", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "flash: slice 'c1' backend 'zephyr_west_flash' needs one of [\"west\"] on PATH; none found.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[missing-tool-skips-with-flag]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "flash: slice 'c1' backend 'zephyr_west_flash' needs one of [\"west\"] on PATH; none found. (skipped via --skip-missing-tools)", - "method": "zephyr_west_flash", - "rc": -1, - "status": "skipped" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.nothing-matched", - "message": "flash: nothing matched the requested filters.", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[multi-segment-interface-is-allowed]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run openocd -f interface/ftdi/olimex-arm-usb-ocd-h.cfg -f target/gd32g553.cfg -c program \\.\\build\\a.elf verify reset exit", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[negative-base-is-refused]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "flash_args.base = -8 is negative; refusing to interpret it as an address/count -- this plans a real flash write.", - "method": "swd_probe", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "flash_args.base = -8 is negative; refusing to interpret it as an address/count -- this plans a real flash write.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[newline-in-base-is-refused]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "flash_args.base = \"0x8000\\n r\" is not a plain hex/decimal address -- refusing to interpolate it into a J-Link/OpenOCD command.", - "method": "swd_probe", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "flash_args.base = \"0x8000\\n r\" is not a plain hex/decimal address -- refusing to interpolate it into a J-Link/OpenOCD command.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[no-artefact-dry-run-previews]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run west flash --build-dir \\.\\build", - "method": "zephyr_west_flash", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[no-artefact-real-run-fails]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "flash: slice 'c1' has no output_artefact / firmware_path; can't flash.", - "method": "zephyr_west_flash", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "flash: slice 'c1' has no output_artefact / firmware_path; can't flash.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[no-hw-info-block]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "a", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[no-slices]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.nothing-matched", - "message": "flash: nothing matched the requested filters.", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[off-core-skips-without-failing-the-run]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "a", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - }, - { - "id": "idle", - "kind": "slice", - "message": "flash: slice 'idle' has no flash_method; skipping", - "rc": -1, - "status": "skipped" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[openocd-forced-bin-appends-base]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run openocd -f interface/cmsis-dap.cfg -f target/gd32g553.cfg -c program \\.\\build\\a.bin verify reset exit 0x08000000", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[openocd-missing-interface-and-target]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "swd_probe: flash_args.interface and flash_args.target are required for the openocd/pyocd path (e.g. interface=cmsis-dap, target=gd32g553) -- or install SEGGER J-Link for the primary path.", - "method": "swd_probe", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "swd_probe: flash_args.interface and flash_args.target are required for the openocd/pyocd path (e.g. interface=cmsis-dap, target=gd32g553) -- or install SEGGER J-Link for the primary path.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[pyocd-forced-elf-omits-base]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run pyocd flash --target t \\.\\build\\a.elf", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[quoted-bool-is-refused]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "flash_args.erase must be a bare boolean (true/false, unquoted; got String(\"true\")) -- refusing to silently fall back to a default -- this plans a real flash write.", - "method": "zephyr_west_flash", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "flash_args.erase must be a bare boolean (true/false, unquoted; got String(\"true\")) -- refusing to silently fall back to a default -- this plans a real flash write.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[quoted-int-is-refused]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "flash_args.jlink_speed must be a bare number (unquoted; got String(\"8000\")) -- refusing to silently fall back to a default -- this plans a real flash write.", - "method": "swd_probe", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "flash_args.jlink_speed must be a bare number (unquoted; got String(\"8000\")) -- refusing to silently fall back to a default -- this plans a real flash write.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[reset-false-is-honoured]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[schema-version-2]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.manifest-invalid", - "message": "\\.\\build\\system-manifest.yaml: unsupported system-manifest schema_version 2 (this CLI consumes v1); upgrade the CLI or the SDK so the versions match", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[sdk-root-invalid]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.sdk-root-not-found", - "message": "Cannot locate alp-sdk root.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[sequence-base-is-refused]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "flash_args.base must be a quoted string (got Sequence [Number(1), Number(2)]); refusing to silently fall back to a default -- this plans a real flash write.", - "method": "swd_probe", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "flash_args.base must be a quoted string (got Sequence [Number(1), Number(2)]); refusing to silently fall back to a default -- this plans a real flash write.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[slice-status-not-ok-is-refused]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.slice-not-built", - "message": "flash: slice 'a' build status is 'failed' (not 'ok'); refusing to flash its artefact -- it may be stale from a previous successful build. Rebuild it first.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[tcl-metacharacter-in-interface-is-refused]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "flash_args.interface = \"a;b\" is not a plain identifier or '/'-separated path of plain identifiers (letters, digits, '-', '_' per segment) -- refusing to interpolate it into a spawned command / OpenOCD Tcl script.", - "method": "swd_probe", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "flash_args.interface = \"a;b\" is not a plain identifier or '/'-separated path of plain identifiers (letters, digits, '-', '_' per segment) -- refusing to interpolate it into a spawned command / OpenOCD Tcl script.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[traversal-in-target-is-refused]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "flash_args.target = \"../../x\" is not a plain identifier or '/'-separated path of plain identifiers (letters, digits, '-', '_' per segment) -- refusing to interpolate it into a spawned command / OpenOCD Tcl script.", - "method": "swd_probe", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "flash_args.target = \"../../x\" is not a plain identifier or '/'-separated path of plain identifiers (letters, digits, '-', '_' per segment) -- refusing to interpolate it into a spawned command / OpenOCD Tcl script.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[west-build-dir-from-zephyr-subdir]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run west flash --build-dir \\.\\build\\c1-zephyr", - "method": "zephyr_west_flash", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[west-runner-and-erase]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run west flash --build-dir \\.\\build --runner openocd --erase", - "method": "zephyr_west_flash", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[xspi-confirmed-is-hw-gated]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "xspi_flashwriter: the real SCIF write is HW-gated and not yet validated on silicon (bench shelved). Run with --dry-run; see docs/provisioning.md.", - "method": "xspi_flashwriter", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "xspi_flashwriter: the real SCIF write is HW-gated and not yet validated on silicon (bench shelved). Run with --dry-run; see docs/provisioning.md.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[xspi-partition-must-be-mtd0-or-mtd1]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "xspi_flashwriter: flash_args.flash_partition must be 'mtd0' (bl2) or 'mtd1' (fip)", - "method": "xspi_flashwriter", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "xspi_flashwriter: flash_args.flash_partition must be 'mtd0' (bl2) or 'mtd1' (fip)", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[xspi-unconfirmed-is-planned]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run flash-writer-scif port=COM3 writer= baud=921600 partition=mtd1 artefact=a.elf -- NOT written: flash_args.confirm is false (set ALP_FLASH_FORCE=1 or flash_args.confirm: true to actually flash)", - "method": "xspi_flashwriter", - "rc": 0, - "status": "planned" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.confirm-required", - "message": "would run flash-writer-scif port=COM3 writer= baud=921600 partition=mtd1 artefact=a.elf -- NOT written: flash_args.confirm is false (set ALP_FLASH_FORCE=1 or flash_args.confirm: true to actually flash)", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[yocto-alias-method-resolves]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run dd if=\\.\\build\\a.elf of=/dev/sdb bs=4M conv=fsync status=progress", - "method": "yocto_wic_to_sd_or_emmc", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[yocto-target-is-required]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "yocto_wic: flash_args.target is required (e.g. /dev/sdb)", - "method": "yocto_wic", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "yocto_wic: flash_args.target is required (e.g. /dev/sdb)", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[yocto-target-must-be-a-device]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "yocto_wic: refusing target './oops' -- must start with /dev/ to avoid clobbering a regular file. Set flash_args.target to a real block device.", - "method": "yocto_wic", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "yocto_wic: refusing target './oops' -- must start with /dev/ to avoid clobbering a regular file. Set flash_args.target to a real block device.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[yocto-unconfirmed-is-planned-not-ok]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run dd if=\\.\\build\\a.elf of=/dev/sdb bs=4M conv=fsync status=progress -- NOT written: flash_args.confirm is false (set ALP_FLASH_FORCE=1 or flash_args.confirm: true to actually flash)", - "method": "yocto_wic", - "rc": 0, - "status": "planned" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [ - { - "code": "flash.confirm-required", - "message": "would run dd if=\\.\\build\\a.elf of=/dev/sdb bs=4M conv=fsync status=progress -- NOT written: flash_args.confirm is false (set ALP_FLASH_FORCE=1 or flash_args.confirm: true to actually flash)", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_format_json_before_the_subcommand_is_accepted#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_manifest_shape_errors_agree_on_code_and_exit[malformed-yaml]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.manifest-invalid", - "message": "\\.\\build\\system-manifest.yaml: system-manifest is not valid YAML: did not find expected ',' or ']' at line 2 column 1, while parsing a flow sequence at line 1 column 4", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_manifest_shape_errors_agree_on_code_and_exit[malformed-yaml]#1": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.manifest-invalid", - "message": "\\.\\build\\system-manifest.yaml: system-manifest is not valid YAML: did not find expected ',' or ']' at line 2 column 1, while parsing a flow sequence at line 1 column 4", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_manifest_shape_errors_agree_on_code_and_exit[slice-missing-os]#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.manifest-invalid", - "message": "\\.\\build\\system-manifest.yaml: system-manifest is not valid YAML: slices[0]: missing field `os` at line 3 column 3", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_manifest_shape_errors_agree_on_code_and_exit[slice-missing-os]#1": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.manifest-invalid", - "message": "\\.\\build\\system-manifest.yaml: system-manifest is not valid YAML: slices[0]: missing field `os` at line 3 column 3", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_unknown_method_diverges_by_exactly_the_flow_d_registry_key#0": [ - 1, - { - "command": "flash", - "data": { - "buildRoot": "\\.\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "flash: slice 'c1' uses flash_method 'bogus_thing' which has no registered backend. Available: [\"baremetal_cmake_flash\", \"swd_probe\", \"xspi_flashwriter\", \"yocto_wic\", \"yocto_wic_to_sd_or_emmc\", \"zephyr_west_flash\"]", - "method": "bogus_thing", - "rc": 1, - "status": "failed" - } - ], - "schemaVersion": "1" - }, - "exitCode": 1, - "issues": [ - { - "code": "flash.entry-failed", - "message": "flash: slice 'c1' uses flash_method 'bogus_thing' which has no registered backend. Available: [\"baremetal_cmake_flash\", \"swd_probe\", \"xspi_flashwriter\", \"yocto_wic\", \"yocto_wic_to_sd_or_emmc\", \"zephyr_west_flash\"]", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_workspace_root_and_app_path_are_separate_anchors[subdir-app-path-discovered-sdk]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\app\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_workspace_root_and_app_path_are_separate_anchors[subdir-app-path-explicit-build-root]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\app/build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_flash_oracle_parity.py::test_workspace_root_and_app_path_are_separate_anchors[subdir-app-path-explicit-sdk]#0": [ - 0, - { - "command": "flash", - "data": { - "buildRoot": "\\app\\build", - "entries": [ - { - "id": "c1", - "kind": "slice", - "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", - "method": "swd_probe", - "rc": 0, - "status": "ok" - } - ], - "schemaVersion": "1" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "./sdk", - "sourceTier": "sdkRootFlag" - } - } - ] -} +{ + "tests/parity/test_flash_oracle_parity.py::test_a_real_spawn_diffs_including_the_captured_failure_tail#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "yocto_wic[c1]: dd: failed to open '\\.\\build\\a.elf': No such file or directory", + "method": "yocto_wic", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "yocto_wic[c1]: dd: failed to open '\\.\\build\\a.elf': No such file or directory", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[absolute-artefact-passes-through]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "a", + "kind": "slice", + "message": "would run west flash --build-dir C:/abs/tree", + "method": "zephyr_west_flash", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[bare-int-base-round-trips-to-hex]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[boot-order-order-and-both-warnings]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "a", + "kind": "slice", + "message": "would run west flash --build-dir \\.\\build", + "method": "zephyr_west_flash", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.boot-order-unknown-core", + "message": "flash: slice 'b' has no boot_order entry; not flashed", + "severity": "warning" + }, + { + "code": "flash.boot-order-unknown-core", + "message": "flash: boot_order references core 'ghost' not in slices; skipping", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[cmake-jobs-and-config]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run cmake --build \\.\\build --target prog --config Rel -j 4", + "method": "baremetal_cmake_flash", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[core-and-helper-together-select-nothing]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.nothing-matched", + "message": "flash: nothing matched the requested filters.", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[core-filter-matches-nothing]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.nothing-matched", + "message": "flash: nothing matched the requested filters.", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[core-filter-suppresses-the-missing-boot-order-warning]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "a", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[empty-boot-order-sorts-and-helpers-last]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "a55_cluster", + "kind": "slice", + "message": "would run dd if=\\.\\build\\a.wic of=/dev/sdb bs=4M conv=fsync status=progress", + "method": "yocto_wic", + "rc": 0, + "status": "ok" + }, + { + "id": "m33_sm", + "kind": "slice", + "message": "would run west flash --build-dir \\.\\build", + "method": "zephyr_west_flash", + "rc": 0, + "status": "ok" + }, + { + "id": "gd32_bridge", + "kind": "helper", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[empty-core-id-is-dropped]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.nothing-matched", + "message": "flash: nothing matched the requested filters.", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[explicit-build-dir-wins-over-the-derived-one]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run west flash --build-dir /elsewhere/bd --hex-file /h/x.hex", + "method": "zephyr_west_flash", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[explicit-relative-build-root]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[explicit-zero-speed-means-default]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[flash-args-tbd-bare-string]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "h1", + "kind": "helper", + "message": "flash: helper 'h1' has an unresolved 'TBD' flash_arg (e.g. mode/device not finalised); skipping", + "method": "swd_probe", + "rc": -1, + "status": "skipped" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.nothing-matched", + "message": "flash: nothing matched the requested filters.", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[flash-args-tbd-mapping]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "h1", + "kind": "helper", + "message": "flash: helper 'h1' has an unresolved 'TBD' flash_arg (e.g. mode/device not finalised); skipping", + "method": "swd_probe", + "rc": -1, + "status": "skipped" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.nothing-matched", + "message": "flash: nothing matched the requested filters.", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[helper-filter-suppresses-slices-entirely]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "h1", + "kind": "helper", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[helper-no-flash-method]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "h1", + "kind": "helper", + "message": "flash: helper 'h1' has no flash_method; skipping", + "rc": -1, + "status": "skipped" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.nothing-matched", + "message": "flash: nothing matched the requested filters.", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[helper-update-channel-is-not-a-flash-target]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "cc3501e_otp", + "kind": "helper", + "message": "flash: helper 'cc3501e_otp' is Alp-OTA-updated (update_channel: alp_ota_spi_otp), not a customer flash target; skipping", + "rc": -1, + "status": "skipped" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.nothing-matched", + "message": "flash: nothing matched the requested filters.", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[jlink-bin-artefact-uses-loadbin]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run JLinkExe -device NRF_DUMMY -if SWD -speed 1000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[malformed-boot-order-steps-are-dropped]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "a", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[manifest-missing]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.manifest-not-found", + "message": "system-manifest.yaml not found at \\.\\build\\system-manifest.yaml; run `tan build --project .` first.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[missing-tool-fails]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "flash: slice 'c1' backend 'zephyr_west_flash' needs one of [\"west\"] on PATH; none found.", + "method": "zephyr_west_flash", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "flash: slice 'c1' backend 'zephyr_west_flash' needs one of [\"west\"] on PATH; none found.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[missing-tool-skips-with-flag]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "flash: slice 'c1' backend 'zephyr_west_flash' needs one of [\"west\"] on PATH; none found. (skipped via --skip-missing-tools)", + "method": "zephyr_west_flash", + "rc": -1, + "status": "skipped" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.nothing-matched", + "message": "flash: nothing matched the requested filters.", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[multi-segment-interface-is-allowed]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run openocd -f interface/ftdi/olimex-arm-usb-ocd-h.cfg -f target/gd32g553.cfg -c program \\.\\build\\a.elf verify reset exit", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[negative-base-is-refused]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "flash_args.base = -8 is negative; refusing to interpret it as an address/count -- this plans a real flash write.", + "method": "swd_probe", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "flash_args.base = -8 is negative; refusing to interpret it as an address/count -- this plans a real flash write.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[newline-in-base-is-refused]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "flash_args.base = \"0x8000\\n r\" is not a plain hex/decimal address -- refusing to interpolate it into a J-Link/OpenOCD command.", + "method": "swd_probe", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "flash_args.base = \"0x8000\\n r\" is not a plain hex/decimal address -- refusing to interpolate it into a J-Link/OpenOCD command.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[no-artefact-dry-run-previews]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run west flash --build-dir \\.\\build", + "method": "zephyr_west_flash", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[no-artefact-real-run-fails]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "flash: slice 'c1' has no output_artefact / firmware_path; can't flash.", + "method": "zephyr_west_flash", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "flash: slice 'c1' has no output_artefact / firmware_path; can't flash.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[no-hw-info-block]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "a", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[no-slices]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.nothing-matched", + "message": "flash: nothing matched the requested filters.", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[off-core-skips-without-failing-the-run]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "a", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + }, + { + "id": "idle", + "kind": "slice", + "message": "flash: slice 'idle' has no flash_method; skipping", + "rc": -1, + "status": "skipped" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[openocd-forced-bin-appends-base]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run openocd -f interface/cmsis-dap.cfg -f target/gd32g553.cfg -c program \\.\\build\\a.bin verify reset exit 0x08000000", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[openocd-missing-interface-and-target]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "swd_probe: flash_args.interface and flash_args.target are required for the openocd/pyocd path (e.g. interface=cmsis-dap, target=gd32g553) -- or install SEGGER J-Link for the primary path.", + "method": "swd_probe", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "swd_probe: flash_args.interface and flash_args.target are required for the openocd/pyocd path (e.g. interface=cmsis-dap, target=gd32g553) -- or install SEGGER J-Link for the primary path.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[pyocd-forced-elf-omits-base]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run pyocd flash --target t \\.\\build\\a.elf", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[quoted-bool-is-refused]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "flash_args.erase must be a bare boolean (true/false, unquoted; got String(\"true\")) -- refusing to silently fall back to a default -- this plans a real flash write.", + "method": "zephyr_west_flash", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "flash_args.erase must be a bare boolean (true/false, unquoted; got String(\"true\")) -- refusing to silently fall back to a default -- this plans a real flash write.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[quoted-int-is-refused]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "flash_args.jlink_speed must be a bare number (unquoted; got String(\"8000\")) -- refusing to silently fall back to a default -- this plans a real flash write.", + "method": "swd_probe", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "flash_args.jlink_speed must be a bare number (unquoted; got String(\"8000\")) -- refusing to silently fall back to a default -- this plans a real flash write.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[reset-false-is-honoured]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[schema-version-2]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.manifest-invalid", + "message": "\\.\\build\\system-manifest.yaml: unsupported system-manifest schema_version 2 (this CLI consumes v1); upgrade the CLI or the SDK so the versions match", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[sdk-root-invalid]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.sdk-root-not-found", + "message": "Cannot locate alp-sdk root.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[sequence-base-is-refused]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "flash_args.base must be a quoted string (got Sequence [Number(1), Number(2)]); refusing to silently fall back to a default -- this plans a real flash write.", + "method": "swd_probe", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "flash_args.base must be a quoted string (got Sequence [Number(1), Number(2)]); refusing to silently fall back to a default -- this plans a real flash write.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[slice-status-not-ok-is-refused]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.slice-not-built", + "message": "flash: slice 'a' build status is 'failed' (not 'ok'); refusing to flash its artefact -- it may be stale from a previous successful build. Rebuild it first.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[tcl-metacharacter-in-interface-is-refused]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "flash_args.interface = \"a;b\" is not a plain identifier or '/'-separated path of plain identifiers (letters, digits, '-', '_' per segment) -- refusing to interpolate it into a spawned command / OpenOCD Tcl script.", + "method": "swd_probe", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "flash_args.interface = \"a;b\" is not a plain identifier or '/'-separated path of plain identifiers (letters, digits, '-', '_' per segment) -- refusing to interpolate it into a spawned command / OpenOCD Tcl script.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[traversal-in-target-is-refused]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "flash_args.target = \"../../x\" is not a plain identifier or '/'-separated path of plain identifiers (letters, digits, '-', '_' per segment) -- refusing to interpolate it into a spawned command / OpenOCD Tcl script.", + "method": "swd_probe", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "flash_args.target = \"../../x\" is not a plain identifier or '/'-separated path of plain identifiers (letters, digits, '-', '_' per segment) -- refusing to interpolate it into a spawned command / OpenOCD Tcl script.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[west-build-dir-from-zephyr-subdir]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run west flash --build-dir \\.\\build\\c1-zephyr", + "method": "zephyr_west_flash", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[west-runner-and-erase]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run west flash --build-dir \\.\\build --runner openocd --erase", + "method": "zephyr_west_flash", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[xspi-confirmed-is-hw-gated]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "xspi_flashwriter: the real SCIF write is HW-gated and not yet validated on silicon (bench shelved). Run with --dry-run; see docs/provisioning.md.", + "method": "xspi_flashwriter", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "xspi_flashwriter: the real SCIF write is HW-gated and not yet validated on silicon (bench shelved). Run with --dry-run; see docs/provisioning.md.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[xspi-partition-must-be-mtd0-or-mtd1]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "xspi_flashwriter: flash_args.flash_partition must be 'mtd0' (bl2) or 'mtd1' (fip)", + "method": "xspi_flashwriter", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "xspi_flashwriter: flash_args.flash_partition must be 'mtd0' (bl2) or 'mtd1' (fip)", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[xspi-unconfirmed-is-planned]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run flash-writer-scif port=COM3 writer= baud=921600 partition=mtd1 artefact=a.elf -- NOT written: flash_args.confirm is false (set ALP_FLASH_FORCE=1 or flash_args.confirm: true to actually flash)", + "method": "xspi_flashwriter", + "rc": 0, + "status": "planned" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.confirm-required", + "message": "would run flash-writer-scif port=COM3 writer= baud=921600 partition=mtd1 artefact=a.elf -- NOT written: flash_args.confirm is false (set ALP_FLASH_FORCE=1 or flash_args.confirm: true to actually flash)", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[yocto-alias-method-resolves]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run dd if=\\.\\build\\a.elf of=/dev/sdb bs=4M conv=fsync status=progress", + "method": "yocto_wic_to_sd_or_emmc", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[yocto-target-is-required]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "yocto_wic: flash_args.target is required (e.g. /dev/sdb)", + "method": "yocto_wic", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "yocto_wic: flash_args.target is required (e.g. /dev/sdb)", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[yocto-target-must-be-a-device]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "yocto_wic: refusing target './oops' -- must start with /dev/ to avoid clobbering a regular file. Set flash_args.target to a real block device.", + "method": "yocto_wic", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "yocto_wic: refusing target './oops' -- must start with /dev/ to avoid clobbering a regular file. Set flash_args.target to a real block device.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_flash_matches_the_rust_oracle[yocto-unconfirmed-is-planned-not-ok]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run dd if=\\.\\build\\a.elf of=/dev/sdb bs=4M conv=fsync status=progress -- NOT written: flash_args.confirm is false (set ALP_FLASH_FORCE=1 or flash_args.confirm: true to actually flash)", + "method": "yocto_wic", + "rc": 0, + "status": "planned" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [ + { + "code": "flash.confirm-required", + "message": "would run dd if=\\.\\build\\a.elf of=/dev/sdb bs=4M conv=fsync status=progress -- NOT written: flash_args.confirm is false (set ALP_FLASH_FORCE=1 or flash_args.confirm: true to actually flash)", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_format_json_before_the_subcommand_is_accepted#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_manifest_shape_errors_agree_on_code_and_exit[malformed-yaml]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.manifest-invalid", + "message": "\\.\\build\\system-manifest.yaml: system-manifest is not valid YAML: did not find expected ',' or ']' at line 2 column 1, while parsing a flow sequence at line 1 column 4", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_manifest_shape_errors_agree_on_code_and_exit[malformed-yaml]#1": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.manifest-invalid", + "message": "\\.\\build\\system-manifest.yaml: system-manifest is not valid YAML: did not find expected ',' or ']' at line 2 column 1, while parsing a flow sequence at line 1 column 4", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_manifest_shape_errors_agree_on_code_and_exit[slice-missing-os]#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.manifest-invalid", + "message": "\\.\\build\\system-manifest.yaml: system-manifest is not valid YAML: slices[0]: missing field `os` at line 3 column 3", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_manifest_shape_errors_agree_on_code_and_exit[slice-missing-os]#1": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.manifest-invalid", + "message": "\\.\\build\\system-manifest.yaml: system-manifest is not valid YAML: slices[0]: missing field `os` at line 3 column 3", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_unknown_method_diverges_by_exactly_the_flow_d_registry_key#0": [ + 1, + { + "command": "flash", + "data": { + "buildRoot": "\\.\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "flash: slice 'c1' uses flash_method 'bogus_thing' which has no registered backend. Available: [\"baremetal_cmake_flash\", \"swd_probe\", \"xspi_flashwriter\", \"yocto_wic\", \"yocto_wic_to_sd_or_emmc\", \"zephyr_west_flash\"]", + "method": "bogus_thing", + "rc": 1, + "status": "failed" + } + ], + "schemaVersion": "1" + }, + "exitCode": 1, + "issues": [ + { + "code": "flash.entry-failed", + "message": "flash: slice 'c1' uses flash_method 'bogus_thing' which has no registered backend. Available: [\"baremetal_cmake_flash\", \"swd_probe\", \"xspi_flashwriter\", \"yocto_wic\", \"yocto_wic_to_sd_or_emmc\", \"zephyr_west_flash\"]", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_workspace_root_and_app_path_are_separate_anchors[subdir-app-path-discovered-sdk]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\app\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_workspace_root_and_app_path_are_separate_anchors[subdir-app-path-explicit-build-root]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\app/build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_flash_oracle_parity.py::test_workspace_root_and_app_path_are_separate_anchors[subdir-app-path-explicit-sdk]#0": [ + 0, + { + "command": "flash", + "data": { + "buildRoot": "\\app\\build", + "entries": [ + { + "id": "c1", + "kind": "slice", + "message": "would run JLinkExe -device GD32G553MEY7TR -if SWD -speed 4000 -AutoConnect 1 -ExitOnError 1 -NoGui 1 -CommanderScript ", + "method": "swd_probe", + "rc": 0, + "status": "ok" + } + ], + "schemaVersion": "1" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "./sdk", + "sourceTier": "sdkRootFlag" + } + } + ] +} diff --git a/python/tests/parity/oracle_fixtures/test_image_size_oracle.json b/python/tests/parity/oracle_fixtures/test_image_size_oracle.json index 1539253c..de4489ba 100644 --- a/python/tests/parity/oracle_fixtures/test_image_size_oracle.json +++ b/python/tests/parity/oracle_fixtures/test_image_size_oracle.json @@ -1,2670 +1,2670 @@ -{ - "tests/parity/test_image_size_oracle.py::test_a_hostile_home_does_not_break_sdk_resolution[bogus]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SoM preset for E1M-X", - "core_id": "c", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\c-zephyr\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_a_hostile_home_does_not_break_sdk_resolution[empty]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SoM preset for E1M-X", - "core_id": "c", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\c-zephyr\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_a_project_that_does_not_exist_parity[image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-unavailable", - "message": "system-manifest.yaml not found at /nowhere\\build\\system-manifest.yaml; run `tan build` first.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "/nowhere" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_a_project_that_does_not_exist_parity[size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-unavailable", - "message": "no system-manifest.yaml at /nowhere\\build\\system-manifest.yaml; run `tan build` first (The system cannot find the path specified. (os error 3)).", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "/nowhere" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_an_embedded_nul_in_a_helper_firmware_path_parity#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.helper-missing", - "message": "image: helper-mcu firmware not found at a\u0000b; refusing to produce an incomplete bundle", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_an_embedded_nul_in_a_path_field_parity[build_dir-image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [ - { - "code": "image.slice-skipped", - "message": "image: skipping c (build_dir missing)", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_an_embedded_nul_in_a_path_field_parity[build_dir-size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SKU in manifest", - "core_id": "c", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\a\u0000b\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_an_embedded_nul_in_a_path_field_parity[output_artefact-image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [ - { - "code": "image.slice-skipped", - "message": "image: skipping c (build_dir missing)", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_an_embedded_nul_in_a_path_field_parity[output_artefact-size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SKU in manifest", - "core_id": "c", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\a\u0000b.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[boot_order-scalar-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: boot_order: invalid type: string \"notalist\", expected a sequence at line 2 column 13", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[boot_order-scalar-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: boot_order: invalid type: string \"notalist\", expected a sequence at line 2 column 13", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[endpoints-scalar-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: ipc[0].endpoints: invalid type: string \"nope\", expected a sequence at line 5 column 14", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[endpoints-scalar-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: ipc[0].endpoints: invalid type: string \"nope\", expected a sequence at line 5 column 14", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-core-schema-image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": { - "a": "yes", - "b": "007", - "c": "1:30", - "d": "2024-01-01", - "e": 15, - "f": 165, - "g": 1.5 - }, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-core-schema-size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-int-beyond-serde-range-image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-int-beyond-serde-range-size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-null-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info: invalid type: unit value, expected struct HwInfo at line 2 column 10", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-null-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info: invalid type: unit value, expected struct HwInfo at line 2 column 10", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-scalar-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info: invalid type: string \"notamapping\", expected struct HwInfo at line 2 column 10", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-scalar-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info: invalid type: string \"notamapping\", expected struct HwInfo at line 2 column 10", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[null-required-vs-optional-image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": { - "sku": null - }, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[null-required-vs-optional-size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no Zephyr image (Yocto/baremetal)", - "core_id": "c", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "os": "~", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "n/a" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[sku-sequence-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info.sku: invalid type: sequence, expected a string at line 3 column 8", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[sku-sequence-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info.sku: invalid type: sequence, expected a string at line 3 column 8", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[slices-null-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: slices: invalid type: unit value, expected a sequence at line 2 column 9", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[slices-null-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: slices: invalid type: unit value, expected a sequence at line 2 column 9", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[storage-scalar-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: storage: invalid type: string \"nope\", expected a sequence at line 2 column 10", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[storage-scalar-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: storage: invalid type: string \"nope\", expected a sequence at line 2 column 10", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[string-fields-keep-raw-text-image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": { - "sku": 16 - }, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[string-fields-keep-raw-text-size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SoM preset for 0x10", - "core_id": "007", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\0o17\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_i18_nested_elf_diverges_from_the_oracle#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)", - "core_id": "m55_hp", - "flash": { - "pct": null, - "total": 5767168, - "used": null - }, - "notes": [ - "no footprint source at \\br\\m55_hp-zephyr\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": 1310720, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "sdk", - "sourceTier": "sdkRootFlag" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_i18_nested_footprint_json_diverges_from_the_oracle#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SKU in manifest", - "core_id": "m55_hp", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\m55_hp-zephyr\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_image_helper_basename_collision#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [ - { - "artefact": "helper-mcus/zephyr.bin", - "chip": "gd32g553", - "name": "gd32_bridge", - "sha256": "8730ead38036367126b4f0b84da6cdc0022932f0ed6f11942856a9437377afd4", - "size": 12 - }, - { - "artefact": "helper-mcus/2-zephyr.bin", - "chip": "cc3501e", - "name": "cc3501e_otp", - "sha256": "f030e4ca8621b006dc792a9b6df477987ecf65b04902121ca9d0dc002d499a7b", - "size": 15 - } - ], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_image_helper_states#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.helper-missing", - "message": "image: helper-mcu firmware not found at firmware/gd32.bin; refusing to produce an incomplete bundle", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_image_helper_states#1": [ - 1, - { - "__raw__": "" - }, - "image: helper-mcu firmware not found at firmware/gd32.bin; refusing to produce an incomplete bundle\nimage: bundle ready at \\hard\\image-bundle\n" - ], - "tests/parity/test_image_size_oracle.py::test_image_helper_states#2": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": { - "sku": "E1M-AEN701" - }, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [ - { - "code": "image.helper-skipped", - "message": "image: helper-mcu firmware not found at TBD; skipping", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_image_missing_manifest#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-unavailable", - "message": "system-manifest.yaml not found at \\br\\system-manifest.yaml; run `tan build` first.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_image_missing_manifest#1": [ - 1, - { - "__raw__": "" - }, - "image: system-manifest.yaml not found at \\br\\system-manifest.yaml; run `tan build` first.\n" - ], - "tests/parity/test_image_size_oracle.py::test_image_ok_slice_helper_and_hw_info_passthrough#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [ - "m55_hp" - ], - "generated_by": "tan image", - "helper_mcus": [ - { - "artefact": "helper-mcus/zephyr.bin", - "chip": "gd32g553", - "name": "gd32_bridge", - "sha256": "8730ead38036367126b4f0b84da6cdc0022932f0ed6f11942856a9437377afd4", - "size": 12 - } - ], - "hw_info": { - "eeprom": { - "magic": "keep" - }, - "sku": "E1M-AEN701" - }, - "schema_version": 1, - "slices": [ - { - "artefact": "slices/m55_hp-zephyr.tar.gz", - "core_id": "m55_hp", - "os": "zephyr", - "sha256": "55213ab74d5a4fc1792498d482bad5b561c210c4ba8e187d0a90dbd7ce082137", - "size": 159 - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_image_slice_skip_and_unsafe_name#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [ - { - "code": "image.slice-skipped", - "message": "image: skipping m55_hp (build_dir missing)", - "severity": "warning" - }, - { - "code": "image.slice-skipped", - "message": "image: skipping m55_he (build_dir missing)", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_image_slice_skip_and_unsafe_name#1": [ - 0, - { - "__raw__": "" - }, - "image: skipping m55_hp (build_dir missing)\nimage: skipping m55_he (build_dir missing)\nimage: bundle ready at \\skip\\image-bundle\n" - ], - "tests/parity/test_image_size_oracle.py::test_image_slice_skip_and_unsafe_name#2": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [ - { - "code": "image.slice-unsafe-name", - "message": "image: skipping ../../../../escape (core_id/os is not a safe archive name)", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[bad-version-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: unsupported system-manifest schema_version 2 (this CLI consumes v1); upgrade the CLI or the SDK so the versions match", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[bad-version-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: unsupported system-manifest schema_version 2 (this CLI consumes v1); upgrade the CLI or the SDK so the versions match", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[bad-yaml-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: did not find expected node content at line 3 column 1, while parsing a flow node", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[bad-yaml-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: did not find expected node content at line 3 column 1, while parsing a flow node", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[no-version-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: missing field `schema_version`", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[no-version-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: missing field `schema_version`", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[scalar-root-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: invalid type: string \"just-a-string\", expected struct SystemManifest", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[scalar-root-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: invalid type: string \"just-a-string\", expected struct SystemManifest", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[slice-no-core-id-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: slices[0]: missing field `core_id` at line 3 column 3", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[slice-no-core-id-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-invalid", - "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: slices[0]: missing field `core_id` at line 3 column 3", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_manifest_path_is_a_directory_parity[image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-unavailable", - "message": "system-manifest.yaml not found at \\br\\system-manifest.yaml; run `tan build` first.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_manifest_path_is_a_directory_parity[size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-unavailable", - "message": "no system-manifest.yaml at \\br\\system-manifest.yaml; run `tan build` first (Access is denied. (os error 5)).", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_no_color_and_ci_flags_are_accepted_by_both#0": [ - 0, - { - "__raw__": "" - }, - "CORE OS FLASH used/total RAM used/total STATUS\n----------------------------------------------------------------------------------\nc zephyr ?/? - ?/? - not built\n -> no SKU in manifest\n" - ], - "tests/parity/test_image_size_oracle.py::test_no_color_and_ci_flags_are_accepted_by_both#1": [ - 0, - { - "__raw__": "" - }, - "CORE OS FLASH used/total RAM used/total STATUS\n----------------------------------------------------------------------------------\nc zephyr ?/? - ?/? - not built\n -> no SKU in manifest\n" - ], - "tests/parity/test_image_size_oracle.py::test_non_utf8_manifest_parity[image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-unavailable", - "message": "system-manifest.yaml not found at \\br\\system-manifest.yaml; run `tan build` first.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_non_utf8_manifest_parity[size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-unavailable", - "message": "no system-manifest.yaml at \\br\\system-manifest.yaml; run `tan build` first (stream did not contain valid UTF-8).", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[dot-image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": { - "sku": "E1M-X" - }, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[dot-size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SoM preset for E1M-X", - "core_id": "c", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\.\\c-zephyr\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[dot-slash-image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": { - "sku": "E1M-X" - }, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[dot-slash-size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SoM preset for E1M-X", - "core_id": "c", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\./c-zephyr\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[empty-image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": { - "sku": "E1M-X" - }, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[empty-size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SoM preset for E1M-X", - "core_id": "c", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\c-zephyr\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[bare-image]#0": [ - 1, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "image.manifest-unavailable", - "message": "system-manifest.yaml not found at \\build\\system-manifest.yaml; run `tan build` first.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[bare-size]#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-unavailable", - "message": "no system-manifest.yaml at \\build\\system-manifest.yaml; run `tan build` first (The system cannot find the path specified. (os error 3)).", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[positional-image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": { - "sku": "E1M-X" - }, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[positional-size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[project-flag-image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": { - "sku": "E1M-X" - }, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "/app" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[project-flag-size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "/app" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_root_format_flag_position_parity[image]#0": [ - 0, - { - "command": "image", - "data": { - "boot_order": [], - "generated_by": "tan image", - "helper_mcus": [], - "hw_info": {}, - "schema_version": 1, - "slices": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_root_format_flag_position_parity[size]#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_size_absurd_core_id_and_os_values#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SKU in manifest", - "core_id": "", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\-zephyr\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - }, - { - "budget_note": "no SKU in manifest", - "core_id": "with space", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\with space-zephyr\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - }, - { - "budget_note": "no SKU in manifest", - "core_id": "m55/hp", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\m55/hp-zephyr\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_size_bad_footprint_json_shapes#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SKU in manifest", - "core_id": "a", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\a\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - }, - { - "budget_note": "no SKU in manifest", - "core_id": "b", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\b\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - }, - { - "budget_note": "no SKU in manifest", - "core_id": "c", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\c\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_size_board_override_and_a_bogus_sdk_root#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)", - "core_id": "m55_hp", - "flash": { - "pct": 0.2, - "total": 2097152, - "used": 4096 - }, - "os": "zephyr", - "ram": { - "pct": 0.4, - "total": 524288, - "used": 2048 - }, - "source": "rom/ram.json", - "status": "ok" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "sdk", - "sourceTier": "sdkRootFlag" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_size_board_override_and_a_bogus_sdk_root#1": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SoM preset for E1M-TEST", - "core_id": "m55_hp", - "flash": { - "pct": null, - "total": null, - "used": 4096 - }, - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": 2048 - }, - "source": "rom/ram.json", - "status": "no-budget" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [ - "m55_hp" - ] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_size_garbage_artefact_is_not_built#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SKU in manifest", - "core_id": "m55_hp", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "notes": [ - "no footprint source at \\br\\junk.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_size_measured_slice_and_the_sdk_envelope_key#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)", - "core_id": "m55_hp", - "flash": { - "pct": 0.1, - "total": 5767168, - "used": 4096 - }, - "os": "zephyr", - "ram": { - "pct": 0.2, - "total": 1310720, - "used": 2048 - }, - "source": "rom/ram.json", - "status": "ok" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "sdk", - "sourceTier": "sdkRootFlag" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_size_measures_a_real_elf#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "core_id": "m55_hp", - "flash": { - "pct": 0.0, - "total": 5767168, - "used": 160 - }, - "os": "zephyr", - "ram": { - "pct": 0.0, - "total": 1048576, - "used": 240 - }, - "source": "size-tool", - "status": "ok" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "sdk", - "sourceTier": "sdkRootFlag" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_size_missing_manifest#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.manifest-unavailable", - "message": "no system-manifest.yaml at \\br\\system-manifest.yaml; run `tan build` first (The system cannot find the file specified. (os error 2)).", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_size_over_budget_and_n_a_and_not_built#0": [ - 1, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)", - "core_id": "m55_hp", - "flash": { - "pct": 96153.8, - "total": 104, - "used": 100000 - }, - "os": "zephyr", - "ram": { - "pct": 7.6, - "total": 1310720, - "used": 100000 - }, - "source": "rom/ram.json", - "status": "over" - }, - { - "budget_note": "no Zephyr image (Yocto/baremetal)", - "core_id": "a32_cluster", - "flash": { - "pct": null, - "total": null, - "used": null - }, - "os": "yocto", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "n/a" - }, - { - "budget_note": "flash=soc_flash_mb", - "core_id": "m55_he", - "flash": { - "pct": null, - "total": 104, - "used": null - }, - "notes": [ - "no footprint source at \\br\\nope\\zephyr\\zephyr.elf" - ], - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": null - }, - "source": null, - "status": "not-built" - } - ], - "summary": { - "over_budget": [ - "m55_hp" - ], - "unknown_budget": [] - } - }, - "exitCode": 1, - "issues": [ - { - "code": "size.over-budget", - "message": "size: over budget: [m55_hp].", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "sdk", - "sourceTier": "sdkRootFlag" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_size_over_budget_and_n_a_and_not_built#1": [ - 1, - { - "__raw__": "" - }, - "CORE OS FLASH used/total RAM used/total STATUS\n----------------------------------------------------------------------------------\nm55_hp zephyr 97.7K/104B 96153.8% 97.7K/1.25M 7.6% OVER\n -> flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)\na32_cluster yocto ?/? - ?/? - n/a\n -> no Zephyr image (Yocto/baremetal)\nm55_he zephyr ?/104B - ?/? - not built\n -> flash=soc_flash_mb\nsize: over budget: [m55_hp].\n" - ], - "tests/parity/test_image_size_oracle.py::test_size_preset_variant_resolution_corner_cases#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)", - "core_id": "m55_hp", - "flash": { - "pct": 0.1, - "total": 4194304, - "used": 4096 - }, - "os": "zephyr", - "ram": { - "pct": 0.2, - "total": 1310720, - "used": 2048 - }, - "source": "rom/ram.json", - "status": "ok" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [] - } - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - }, - "sdk": { - "root": "sdk", - "sourceTier": "sdkRootFlag" - } - }, - "" - ], - "tests/parity/test_image_size_oracle.py::test_size_unknown_budget_notice#0": [ - 0, - { - "command": "size", - "data": { - "schema": "alp-size/1", - "slices": [ - { - "budget_note": "no SoM preset for E1M-NOPRESET", - "core_id": "m55_hp", - "flash": { - "pct": null, - "total": null, - "used": 4096 - }, - "os": "zephyr", - "ram": { - "pct": null, - "total": null, - "used": 2048 - }, - "source": "rom/ram.json", - "status": "no-budget" - } - ], - "summary": { - "over_budget": [], - "unknown_budget": [ - "m55_hp" - ] - } - }, - "exitCode": 0, - "issues": [ - { - "code": "size.budget-unknown", - "message": "size: budget unknown for [m55_hp] \u2014 skipped by --fail-over-budget (no guess).", - "severity": "info" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - }, - "" - ] -} +{ + "tests/parity/test_image_size_oracle.py::test_a_hostile_home_does_not_break_sdk_resolution[bogus]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SoM preset for E1M-X", + "core_id": "c", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\c-zephyr\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_a_hostile_home_does_not_break_sdk_resolution[empty]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SoM preset for E1M-X", + "core_id": "c", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\c-zephyr\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_a_project_that_does_not_exist_parity[image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-unavailable", + "message": "system-manifest.yaml not found at /nowhere\\build\\system-manifest.yaml; run `tan build` first.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "/nowhere" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_a_project_that_does_not_exist_parity[size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-unavailable", + "message": "no system-manifest.yaml at /nowhere\\build\\system-manifest.yaml; run `tan build` first (The system cannot find the path specified. (os error 3)).", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "/nowhere" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_an_embedded_nul_in_a_helper_firmware_path_parity#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.helper-missing", + "message": "image: helper-mcu firmware not found at a\u0000b; refusing to produce an incomplete bundle", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_an_embedded_nul_in_a_path_field_parity[build_dir-image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [ + { + "code": "image.slice-skipped", + "message": "image: skipping c (build_dir missing)", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_an_embedded_nul_in_a_path_field_parity[build_dir-size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SKU in manifest", + "core_id": "c", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\a\u0000b\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_an_embedded_nul_in_a_path_field_parity[output_artefact-image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [ + { + "code": "image.slice-skipped", + "message": "image: skipping c (build_dir missing)", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_an_embedded_nul_in_a_path_field_parity[output_artefact-size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SKU in manifest", + "core_id": "c", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\a\u0000b.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[boot_order-scalar-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: boot_order: invalid type: string \"notalist\", expected a sequence at line 2 column 13", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[boot_order-scalar-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: boot_order: invalid type: string \"notalist\", expected a sequence at line 2 column 13", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[endpoints-scalar-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: ipc[0].endpoints: invalid type: string \"nope\", expected a sequence at line 5 column 14", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[endpoints-scalar-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: ipc[0].endpoints: invalid type: string \"nope\", expected a sequence at line 5 column 14", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-core-schema-image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": { + "a": "yes", + "b": "007", + "c": "1:30", + "d": "2024-01-01", + "e": 15, + "f": 165, + "g": 1.5 + }, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-core-schema-size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-int-beyond-serde-range-image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-int-beyond-serde-range-size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-null-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info: invalid type: unit value, expected struct HwInfo at line 2 column 10", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-null-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info: invalid type: unit value, expected struct HwInfo at line 2 column 10", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-scalar-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info: invalid type: string \"notamapping\", expected struct HwInfo at line 2 column 10", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[hw_info-scalar-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info: invalid type: string \"notamapping\", expected struct HwInfo at line 2 column 10", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[null-required-vs-optional-image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": { + "sku": null + }, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[null-required-vs-optional-size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no Zephyr image (Yocto/baremetal)", + "core_id": "c", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "os": "~", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "n/a" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[sku-sequence-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info.sku: invalid type: sequence, expected a string at line 3 column 8", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[sku-sequence-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: hw_info.sku: invalid type: sequence, expected a string at line 3 column 8", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[slices-null-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: slices: invalid type: unit value, expected a sequence at line 2 column 9", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[slices-null-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: slices: invalid type: unit value, expected a sequence at line 2 column 9", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[storage-scalar-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: storage: invalid type: string \"nope\", expected a sequence at line 2 column 10", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[storage-scalar-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: storage: invalid type: string \"nope\", expected a sequence at line 2 column 10", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[string-fields-keep-raw-text-image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": { + "sku": 16 + }, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_field_type_leniency_parity[string-fields-keep-raw-text-size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SoM preset for 0x10", + "core_id": "007", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\0o17\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_i18_nested_elf_diverges_from_the_oracle#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)", + "core_id": "m55_hp", + "flash": { + "pct": null, + "total": 5767168, + "used": null + }, + "notes": [ + "no footprint source at \\br\\m55_hp-zephyr\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": 1310720, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "sdk", + "sourceTier": "sdkRootFlag" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_i18_nested_footprint_json_diverges_from_the_oracle#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SKU in manifest", + "core_id": "m55_hp", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\m55_hp-zephyr\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_image_helper_basename_collision#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [ + { + "artefact": "helper-mcus/zephyr.bin", + "chip": "gd32g553", + "name": "gd32_bridge", + "sha256": "8730ead38036367126b4f0b84da6cdc0022932f0ed6f11942856a9437377afd4", + "size": 12 + }, + { + "artefact": "helper-mcus/2-zephyr.bin", + "chip": "cc3501e", + "name": "cc3501e_otp", + "sha256": "f030e4ca8621b006dc792a9b6df477987ecf65b04902121ca9d0dc002d499a7b", + "size": 15 + } + ], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_image_helper_states#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.helper-missing", + "message": "image: helper-mcu firmware not found at firmware/gd32.bin; refusing to produce an incomplete bundle", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_image_helper_states#1": [ + 1, + { + "__raw__": "" + }, + "image: helper-mcu firmware not found at firmware/gd32.bin; refusing to produce an incomplete bundle\nimage: bundle ready at \\hard\\image-bundle\n" + ], + "tests/parity/test_image_size_oracle.py::test_image_helper_states#2": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": { + "sku": "E1M-AEN701" + }, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [ + { + "code": "image.helper-skipped", + "message": "image: helper-mcu firmware not found at TBD; skipping", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_image_missing_manifest#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-unavailable", + "message": "system-manifest.yaml not found at \\br\\system-manifest.yaml; run `tan build` first.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_image_missing_manifest#1": [ + 1, + { + "__raw__": "" + }, + "image: system-manifest.yaml not found at \\br\\system-manifest.yaml; run `tan build` first.\n" + ], + "tests/parity/test_image_size_oracle.py::test_image_ok_slice_helper_and_hw_info_passthrough#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [ + "m55_hp" + ], + "generated_by": "tan image", + "helper_mcus": [ + { + "artefact": "helper-mcus/zephyr.bin", + "chip": "gd32g553", + "name": "gd32_bridge", + "sha256": "8730ead38036367126b4f0b84da6cdc0022932f0ed6f11942856a9437377afd4", + "size": 12 + } + ], + "hw_info": { + "eeprom": { + "magic": "keep" + }, + "sku": "E1M-AEN701" + }, + "schema_version": 1, + "slices": [ + { + "artefact": "slices/m55_hp-zephyr.tar.gz", + "core_id": "m55_hp", + "os": "zephyr", + "sha256": "55213ab74d5a4fc1792498d482bad5b561c210c4ba8e187d0a90dbd7ce082137", + "size": 159 + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_image_slice_skip_and_unsafe_name#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [ + { + "code": "image.slice-skipped", + "message": "image: skipping m55_hp (build_dir missing)", + "severity": "warning" + }, + { + "code": "image.slice-skipped", + "message": "image: skipping m55_he (build_dir missing)", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_image_slice_skip_and_unsafe_name#1": [ + 0, + { + "__raw__": "" + }, + "image: skipping m55_hp (build_dir missing)\nimage: skipping m55_he (build_dir missing)\nimage: bundle ready at \\skip\\image-bundle\n" + ], + "tests/parity/test_image_size_oracle.py::test_image_slice_skip_and_unsafe_name#2": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [ + { + "code": "image.slice-unsafe-name", + "message": "image: skipping ../../../../escape (core_id/os is not a safe archive name)", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[bad-version-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: unsupported system-manifest schema_version 2 (this CLI consumes v1); upgrade the CLI or the SDK so the versions match", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[bad-version-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: unsupported system-manifest schema_version 2 (this CLI consumes v1); upgrade the CLI or the SDK so the versions match", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[bad-yaml-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: did not find expected node content at line 3 column 1, while parsing a flow node", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[bad-yaml-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: did not find expected node content at line 3 column 1, while parsing a flow node", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[no-version-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: missing field `schema_version`", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[no-version-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: missing field `schema_version`", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[scalar-root-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: invalid type: string \"just-a-string\", expected struct SystemManifest", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[scalar-root-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: invalid type: string \"just-a-string\", expected struct SystemManifest", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[slice-no-core-id-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: slices[0]: missing field `core_id` at line 3 column 3", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_malformed_manifest_parity[slice-no-core-id-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-invalid", + "message": "\\br\\system-manifest.yaml: system-manifest is not valid YAML: slices[0]: missing field `core_id` at line 3 column 3", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_manifest_path_is_a_directory_parity[image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-unavailable", + "message": "system-manifest.yaml not found at \\br\\system-manifest.yaml; run `tan build` first.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_manifest_path_is_a_directory_parity[size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-unavailable", + "message": "no system-manifest.yaml at \\br\\system-manifest.yaml; run `tan build` first (Access is denied. (os error 5)).", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_no_color_and_ci_flags_are_accepted_by_both#0": [ + 0, + { + "__raw__": "" + }, + "CORE OS FLASH used/total RAM used/total STATUS\n----------------------------------------------------------------------------------\nc zephyr ?/? - ?/? - not built\n -> no SKU in manifest\n" + ], + "tests/parity/test_image_size_oracle.py::test_no_color_and_ci_flags_are_accepted_by_both#1": [ + 0, + { + "__raw__": "" + }, + "CORE OS FLASH used/total RAM used/total STATUS\n----------------------------------------------------------------------------------\nc zephyr ?/? - ?/? - not built\n -> no SKU in manifest\n" + ], + "tests/parity/test_image_size_oracle.py::test_non_utf8_manifest_parity[image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-unavailable", + "message": "system-manifest.yaml not found at \\br\\system-manifest.yaml; run `tan build` first.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_non_utf8_manifest_parity[size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-unavailable", + "message": "no system-manifest.yaml at \\br\\system-manifest.yaml; run `tan build` first (stream did not contain valid UTF-8).", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[dot-image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": { + "sku": "E1M-X" + }, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[dot-size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SoM preset for E1M-X", + "core_id": "c", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\.\\c-zephyr\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[dot-slash-image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": { + "sku": "E1M-X" + }, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[dot-slash-size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SoM preset for E1M-X", + "core_id": "c", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\./c-zephyr\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[empty-image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": { + "sku": "E1M-X" + }, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_odd_build_root_values_parity[empty-size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SoM preset for E1M-X", + "core_id": "c", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\c-zephyr\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[bare-image]#0": [ + 1, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "image.manifest-unavailable", + "message": "system-manifest.yaml not found at \\build\\system-manifest.yaml; run `tan build` first.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[bare-size]#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-unavailable", + "message": "no system-manifest.yaml at \\build\\system-manifest.yaml; run `tan build` first (The system cannot find the path specified. (os error 3)).", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[positional-image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": { + "sku": "E1M-X" + }, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[positional-size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[project-flag-image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": { + "sku": "E1M-X" + }, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "/app" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_path_resolution_parity[project-flag-size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "/app" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_root_format_flag_position_parity[image]#0": [ + 0, + { + "command": "image", + "data": { + "boot_order": [], + "generated_by": "tan image", + "helper_mcus": [], + "hw_info": {}, + "schema_version": 1, + "slices": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_root_format_flag_position_parity[size]#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_size_absurd_core_id_and_os_values#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SKU in manifest", + "core_id": "", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\-zephyr\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + }, + { + "budget_note": "no SKU in manifest", + "core_id": "with space", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\with space-zephyr\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + }, + { + "budget_note": "no SKU in manifest", + "core_id": "m55/hp", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\m55/hp-zephyr\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_size_bad_footprint_json_shapes#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SKU in manifest", + "core_id": "a", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\a\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + }, + { + "budget_note": "no SKU in manifest", + "core_id": "b", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\b\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + }, + { + "budget_note": "no SKU in manifest", + "core_id": "c", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\c\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_size_board_override_and_a_bogus_sdk_root#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)", + "core_id": "m55_hp", + "flash": { + "pct": 0.2, + "total": 2097152, + "used": 4096 + }, + "os": "zephyr", + "ram": { + "pct": 0.4, + "total": 524288, + "used": 2048 + }, + "source": "rom/ram.json", + "status": "ok" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "sdk", + "sourceTier": "sdkRootFlag" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_size_board_override_and_a_bogus_sdk_root#1": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SoM preset for E1M-TEST", + "core_id": "m55_hp", + "flash": { + "pct": null, + "total": null, + "used": 4096 + }, + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": 2048 + }, + "source": "rom/ram.json", + "status": "no-budget" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [ + "m55_hp" + ] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_size_garbage_artefact_is_not_built#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SKU in manifest", + "core_id": "m55_hp", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "notes": [ + "no footprint source at \\br\\junk.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_size_measured_slice_and_the_sdk_envelope_key#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)", + "core_id": "m55_hp", + "flash": { + "pct": 0.1, + "total": 5767168, + "used": 4096 + }, + "os": "zephyr", + "ram": { + "pct": 0.2, + "total": 1310720, + "used": 2048 + }, + "source": "rom/ram.json", + "status": "ok" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "sdk", + "sourceTier": "sdkRootFlag" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_size_measures_a_real_elf#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "core_id": "m55_hp", + "flash": { + "pct": 0.0, + "total": 5767168, + "used": 160 + }, + "os": "zephyr", + "ram": { + "pct": 0.0, + "total": 1048576, + "used": 240 + }, + "source": "size-tool", + "status": "ok" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "sdk", + "sourceTier": "sdkRootFlag" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_size_missing_manifest#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.manifest-unavailable", + "message": "no system-manifest.yaml at \\br\\system-manifest.yaml; run `tan build` first (The system cannot find the file specified. (os error 2)).", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_size_over_budget_and_n_a_and_not_built#0": [ + 1, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)", + "core_id": "m55_hp", + "flash": { + "pct": 96153.8, + "total": 104, + "used": 100000 + }, + "os": "zephyr", + "ram": { + "pct": 7.6, + "total": 1310720, + "used": 100000 + }, + "source": "rom/ram.json", + "status": "over" + }, + { + "budget_note": "no Zephyr image (Yocto/baremetal)", + "core_id": "a32_cluster", + "flash": { + "pct": null, + "total": null, + "used": null + }, + "os": "yocto", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "n/a" + }, + { + "budget_note": "flash=soc_flash_mb", + "core_id": "m55_he", + "flash": { + "pct": null, + "total": 104, + "used": null + }, + "notes": [ + "no footprint source at \\br\\nope\\zephyr\\zephyr.elf" + ], + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": null + }, + "source": null, + "status": "not-built" + } + ], + "summary": { + "over_budget": [ + "m55_hp" + ], + "unknown_budget": [] + } + }, + "exitCode": 1, + "issues": [ + { + "code": "size.over-budget", + "message": "size: over budget: [m55_hp].", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "sdk", + "sourceTier": "sdkRootFlag" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_size_over_budget_and_n_a_and_not_built#1": [ + 1, + { + "__raw__": "" + }, + "CORE OS FLASH used/total RAM used/total STATUS\n----------------------------------------------------------------------------------\nm55_hp zephyr 97.7K/104B 96153.8% 97.7K/1.25M 7.6% OVER\n -> flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)\na32_cluster yocto ?/? - ?/? - n/a\n -> no Zephyr image (Yocto/baremetal)\nm55_he zephyr ?/104B - ?/? - not built\n -> flash=soc_flash_mb\nsize: over budget: [m55_hp].\n" + ], + "tests/parity/test_image_size_oracle.py::test_size_preset_variant_resolution_corner_cases#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "flash=soc_flash_mb; ram=core tcm_kb (ITCM+DTCM)", + "core_id": "m55_hp", + "flash": { + "pct": 0.1, + "total": 4194304, + "used": 4096 + }, + "os": "zephyr", + "ram": { + "pct": 0.2, + "total": 1310720, + "used": 2048 + }, + "source": "rom/ram.json", + "status": "ok" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [] + } + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + }, + "sdk": { + "root": "sdk", + "sourceTier": "sdkRootFlag" + } + }, + "" + ], + "tests/parity/test_image_size_oracle.py::test_size_unknown_budget_notice#0": [ + 0, + { + "command": "size", + "data": { + "schema": "alp-size/1", + "slices": [ + { + "budget_note": "no SoM preset for E1M-NOPRESET", + "core_id": "m55_hp", + "flash": { + "pct": null, + "total": null, + "used": 4096 + }, + "os": "zephyr", + "ram": { + "pct": null, + "total": null, + "used": 2048 + }, + "source": "rom/ram.json", + "status": "no-budget" + } + ], + "summary": { + "over_budget": [], + "unknown_budget": [ + "m55_hp" + ] + } + }, + "exitCode": 0, + "issues": [ + { + "code": "size.budget-unknown", + "message": "size: budget unknown for [m55_hp] \u2014 skipped by --fail-over-budget (no guess).", + "severity": "info" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + }, + "" + ] +} diff --git a/python/tests/parity/oracle_fixtures/test_oracle_parity.json b/python/tests/parity/oracle_fixtures/test_oracle_parity.json index f1729b8b..8b1f7a02 100644 --- a/python/tests/parity/oracle_fixtures/test_oracle_parity.json +++ b/python/tests/parity/oracle_fixtures/test_oracle_parity.json @@ -1,1686 +1,1872 @@ -{ - "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[native-host-none]#0": [ - 0, - { - "command": "debug-config", - "data": { - "configuration": { - "cwd": "${workspaceFolder}", - "name": "Alp: Native Sim Debug", - "program": "${workspaceFolder}/build/native_sim-zephyr/build/zephyr/zephyr.exe", - "request": "launch", - "type": "lldb" - }, - "generatedAt": "1970-01-01T00:00:00.000Z", - "launchJsonPath": "\\.vscode\\launch.json", - "notes": [ - "This is a draft launch configuration generated by tan.", - "The long-term target is to resolve these values from the shared debug model." - ], - "preview": true, - "replaced": false, - "schemaVersion": "1", - "server": "none", - "targetKind": "native-host" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[zephyr-mcu-jlink]#0": [ - 0, - { - "command": "debug-config", - "data": { - "configuration": { - "cwd": "${workspaceFolder}", - "device": "AE822F4M55_HP", - "executable": "${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf", - "gdbPath": "/zephyr-sdk/arm-zephyr-eabi-gdb", - "interface": "swd", - "name": "Alp: Zephyr Debug (J-Link)", - "request": "launch", - "runToEntryPoint": "main", - "servertype": "jlink", - "type": "cortex-debug" - }, - "generatedAt": "1970-01-01T00:00:00.000Z", - "launchJsonPath": "\\.vscode\\launch.json", - "notes": [ - "This is a draft launch configuration generated by tan.", - "The long-term target is to resolve these values from the shared debug model." - ], - "preview": true, - "replaced": false, - "schemaVersion": "1", - "server": "jlink", - "targetKind": "zephyr-mcu" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[zephyr-mcu-openocd]#0": [ - 0, - { - "command": "debug-config", - "data": { - "configuration": { - "configFiles": [ - "board/alp.cfg" - ], - "cwd": "${workspaceFolder}", - "executable": "${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf", - "gdbPath": "/zephyr-sdk/arm-zephyr-eabi-gdb", - "name": "Alp: Zephyr Debug (OpenOCD)", - "request": "launch", - "runToEntryPoint": "main", - "searchDir": [ - "/usr/share/openocd/scripts" - ], - "serverpath": "/usr/bin/openocd", - "servertype": "openocd", - "type": "cortex-debug" - }, - "generatedAt": "1970-01-01T00:00:00.000Z", - "launchJsonPath": "\\.vscode\\launch.json", - "notes": [ - "This is a draft launch configuration generated by tan.", - "The long-term target is to resolve these values from the shared debug model." - ], - "preview": true, - "replaced": false, - "schemaVersion": "1", - "server": "openocd", - "targetKind": "zephyr-mcu" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[zephyr-mcu-pyocd]#0": [ - 0, - { - "command": "debug-config", - "data": { - "configuration": { - "cwd": "${workspaceFolder}", - "executable": "${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf", - "gdbPath": "/zephyr-sdk/arm-zephyr-eabi-gdb", - "name": "Alp: Zephyr Debug (pyOCD)", - "request": "launch", - "runToEntryPoint": "main", - "servertype": "pyocd", - "targetId": "", - "type": "cortex-debug" - }, - "generatedAt": "1970-01-01T00:00:00.000Z", - "launchJsonPath": "\\.vscode\\launch.json", - "notes": [ - "This is a draft launch configuration generated by tan.", - "Placeholder fields such as still need project-specific resolution.", - "The long-term target is to resolve these values from the shared debug model.", - "This build registers no 'pyocd' runner (runners.yaml: [\"jlink\", \"openocd\"]), so its fields could not be resolved." - ], - "preview": true, - "replaced": false, - "schemaVersion": "1", - "server": "pyocd", - "targetKind": "zephyr-mcu" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_generate_matches_rust_with_a_resolvable_sdk#0": [ - 0, - { - "__raw__": "" - } - ], - "tests/parity/test_oracle_parity.py::test_generate_matches_rust_with_a_resolvable_sdk#1": [ - 0, - { - "command": "generate", - "data": { - "failed": [], - "schemaVersion": "1", - "targets": [ - "zephyr-conf", - "dts-overlay", - "native-sim-overlay", - "cmake-args", - "yocto-conf", - "carrier-netlist", - "west-libraries", - "hw-info-h", - "os-topology" - ], - "written": [ - "build\\generated\\alp.conf", - "build\\generated\\alp.overlay", - "boards\\native_sim_native_64.overlay", - "build\\generated\\alp-cmake-args.txt", - "build\\generated\\alp-yocto.conf", - "build\\generated\\carrier-netlist.json", - "build\\generated\\alp-west-libs.yml", - "build\\generated\\alp_hw_info_build.h", - "build\\generated\\os-topology.json" - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": "board.yaml", - "root": "." - }, - "sdk": { - "root": "", - "sourceTier": "sdkRootFlag" - } - } - ], - "tests/parity/test_oracle_parity.py::test_harness_reports_a_planted_envelope_difference#0": [ - 2, - { - "__raw__": "" - } - ], - "tests/parity/test_oracle_parity.py::test_harness_reports_a_planted_exit_code_difference#0": [ - 0, - { - "__raw__": "tan 0.4.1" - } - ], - "tests/parity/test_oracle_parity.py::test_harness_reports_a_planted_version_shape_difference[malformed]#0": [ - 0, - { - "__raw__": "tan 0.4.1" - } - ], - "tests/parity/test_oracle_parity.py::test_harness_reports_a_planted_version_shape_difference[trailing-line]#0": [ - 0, - { - "__raw__": "tan 0.4.1" - } - ], - "tests/parity/test_oracle_parity.py::test_harness_reports_a_planted_version_shape_difference[trailing-words]#0": [ - 0, - { - "__raw__": "tan 0.4.1" - } - ], - "tests/parity/test_oracle_parity.py::test_init_sdk_root_flag_pin_is_a_known_divergence_from_the_oracle#0": [ - 0, - { - "command": "init", - "data": { - "destination": ".", - "fileChanges": [ - { - "kind": "new", - "relativePath": "board.yaml" - }, - { - "kind": "new", - "relativePath": "README.md" - }, - { - "kind": "new", - "relativePath": "prj.conf" - }, - { - "kind": "new", - "relativePath": "CMakeLists.txt" - }, - { - "kind": "new", - "relativePath": "src/CMakeLists.txt" - }, - { - "kind": "new", - "relativePath": "include/app/app.h" - }, - { - "kind": "new", - "relativePath": "src/main.c" - }, - { - "kind": "new", - "relativePath": "src/features/app_bootstrap.c" - } - ], - "preview": false, - "schemaVersion": "1", - "sdkPinned": "../rust-sdk", - "templateId": "minimal-app", - "unchanged": [], - "written": [ - "board.yaml", - "README.md", - "prj.conf", - "CMakeLists.txt", - "src/CMakeLists.txt", - "include/app/app.h", - "src/main.c", - "src/features/app_bootstrap.c" - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "." - }, - "sdk": { - "root": "../rust-sdk", - "sourceTier": "sdkRootFlag" - } - }, - "{\n \"sdkPath\": \"../rust-sdk\",\n \"updatedAt\": \"1970-01-01T00:00:00.000Z\"\n}\n" - ], - "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[audio_i2s-tone.build-plan]#0": [ - 0, - { - "command": "build", - "data": { - "boardYaml": "examples/audio/i2s-tone/board.yaml", - "buildRoot": "build", - "executionPolicy": { - "missingTool": "skip", - "nullCommand": "skip", - "unknownBackend": "fail" - }, - "generatedBy": "scripts/alp_orchestrate.py", - "schemaVersion": 1, - "sdkCommit": "97ad481b", - "sdkVersion": "0.11.1", - "sharedArtefacts": [ - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-AEN801\"\n\n/* No ipc[] entries declared in board.yaml; nothing to emit. */\n\n#endif /* ALP_SYSTEM_IPC_H */\n", - "path": "build/generated/alp/system_ipc.h" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n /* No ipc[] carve-outs declared. */\n };\n};\n", - "path": "build/generated/dts-reservations.dtsi" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", - "path": "build/generated/dts-partitions.dtsi" - } - ], - "sku": "E1M-AEN801", - "slices": [ - { - "appDir": null, - "artifacts": { - "bin": null, - "compileCommands": null, - "elf": null, - "map": null, - "sizeReport": null, - "symbols": null - }, - "backend": "yocto", - "buildDir": "build/a32_cluster-yocto", - "command": { - "args": [ - "alp-image-edge" - ], - "cwd": "build/a32_cluster-yocto", - "tool": "bitbake" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- append to local.conf.\n# Per-core slice `a32_cluster` (image: custom)\n# Console: Linux console (kernel `console=` cmdline -> SoM debug UART; userspace -> stdout/tty).\nMACHINE = \"e1m-aen801-a32\"\n", - "path": "build/a32_cluster-yocto/local.conf" - } - ], - "coreId": "a32_cluster", - "debug": { - "console": "linux", - "probe": null - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": null, - "id": "poky-glibc", - "sysroot": null, - "targetTriple": null - } - }, - { - "appDir": "/firmware/alp-stock-shim", - "artifacts": { - "bin": "build/m55_he-zephyr/zephyr/zephyr.bin", - "compileCommands": "build/m55_he-zephyr/compile_commands.json", - "elf": "build/m55_he-zephyr/zephyr/zephyr.elf", - "map": "build/m55_he-zephyr/zephyr/zephyr.map", - "sizeReport": "build/m55_he-zephyr/zephyr/zephyr.stat", - "symbols": "build/m55_he-zephyr/zephyr/zephyr.symbols" - }, - "backend": "zephyr", - "buildDir": "build/m55_he-zephyr", - "command": { - "args": [ - "build", - "-b", - "alp_e1m_aen801_m55_he/ae822fa0e5597ls0/rtss_he", - "/firmware/alp-stock-shim", - "--", - "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" - ], - "cwd": "build/m55_he-zephyr", - "tool": "west" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_he` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_he` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_SPI=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n", - "path": "build/m55_he-zephyr/alp.conf" - } - ], - "coreId": "m55_he", - "debug": { - "console": "uart", - "probe": "openocd" - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": "arm-zephyr-eabi-gcc", - "id": "arm-zephyr-eabi", - "sysroot": null, - "targetTriple": "arm-zephyr-eabi" - } - }, - { - "appDir": "/examples/audio/i2s-tone/src", - "artifacts": { - "bin": "build/m55_hp-zephyr/zephyr/zephyr.bin", - "compileCommands": "build/m55_hp-zephyr/compile_commands.json", - "elf": "build/m55_hp-zephyr/zephyr/zephyr.elf", - "map": "build/m55_hp-zephyr/zephyr/zephyr.map", - "sizeReport": "build/m55_hp-zephyr/zephyr/zephyr.stat", - "symbols": "build/m55_hp-zephyr/zephyr/zephyr.symbols" - }, - "backend": "zephyr", - "buildDir": "build/m55_hp-zephyr", - "command": { - "args": [ - "build", - "-b", - "alp_e1m_aen801_m55_hp/ae822fa0e5597ls0/rtss_hp", - "/examples/audio/i2s-tone", - "--", - "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" - ], - "cwd": "build/m55_hp-zephyr", - "tool": "west" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_hp` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_hp` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_I2S=y\nCONFIG_SPI=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n", - "path": "build/m55_hp-zephyr/alp.conf" - } - ], - "coreId": "m55_hp", - "debug": { - "console": "uart", - "probe": "openocd" - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": "arm-zephyr-eabi-gcc", - "id": "arm-zephyr-eabi", - "sysroot": null, - "targetTriple": "arm-zephyr-eabi" - } - } - ], - "warnings": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[connectivity_iot-fleet-ota.build-plan]#0": [ - 0, - { - "command": "build", - "data": { - "boardYaml": "examples/connectivity/iot-fleet-ota/board.yaml", - "buildRoot": "build", - "executionPolicy": { - "missingTool": "skip", - "nullCommand": "skip", - "unknownBackend": "fail" - }, - "generatedBy": "scripts/alp_orchestrate.py", - "schemaVersion": 1, - "sdkCommit": "97ad481b", - "sdkVersion": "0.11.1", - "sharedArtefacts": [ - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-AEN801\"\n\n/* No ipc[] entries declared in board.yaml; nothing to emit. */\n\n#endif /* ALP_SYSTEM_IPC_H */\n", - "path": "build/generated/alp/system_ipc.h" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n /* No ipc[] carve-outs declared. */\n };\n};\n", - "path": "build/generated/dts-reservations.dtsi" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", - "path": "build/generated/dts-partitions.dtsi" - }, - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py from board.yaml `boot:`.\n# Drives the sysbuild MCUboot child image. Customers who\n# omit `boot:` get the SDK's stock per-family defaults.\n\nSB_CONFIG_BOOTLOADER_MCUBOOT=y\nSB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y\nSB_CONFIG_BOOT_SIGNATURE_KEY_FILE=\"keys/prod_ecdsa_p256.pub.pem\"\nSB_CONFIG_MCUBOOT_MODE_SWAP_SCRATCH=y\n", - "path": "build/alp_sysbuild.conf" - } - ], - "sku": "E1M-AEN801", - "slices": [ - { - "appDir": "/firmware/alp-stock-shim", - "artifacts": { - "bin": "build/m55_he-zephyr/zephyr/zephyr.bin", - "compileCommands": "build/m55_he-zephyr/compile_commands.json", - "elf": "build/m55_he-zephyr/zephyr/zephyr.elf", - "map": "build/m55_he-zephyr/zephyr/zephyr.map", - "sizeReport": "build/m55_he-zephyr/zephyr/zephyr.stat", - "symbols": "build/m55_he-zephyr/zephyr/zephyr.symbols" - }, - "backend": "zephyr", - "buildDir": "build/m55_he-zephyr", - "command": { - "args": [ - "build", - "-b", - "alp_e1m_aen801_m55_he/ae822fa0e5597ls0/rtss_he", - "/firmware/alp-stock-shim", - "--sysbuild", - "--", - "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14", - "-DSB_CONF_FILE=/zephyr/sysbuild/aen/sysbuild.conf;/examples/connectivity/iot-fleet-ota/build/alp_sysbuild.conf" - ], - "cwd": "build/m55_he-zephyr", - "tool": "west" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_he` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_he` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_SPI=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n# OTA Zephyr client (board.yaml `ota.provider: mender`)\n# Mender-MCU-client wiring is pending the v0.7 OTA module\n# (mender-mcu-client west group activation).\n# CONFIG_MENDER_MCU_CLIENT=y\n# CONFIG_MENDER_SERVER_URL=\"https://hosted.mender.io\"\n# CONFIG_MENDER_TENANT_TOKEN=\"${MENDER_TENANT_TOKEN}\"\n# CONFIG_MENDER_ARTIFACT_NAME=\"iot-fleet-ota\"\n# CONFIG_MENDER_UPDATE_POLL_INTERVAL=1800\n\n", - "path": "build/m55_he-zephyr/alp.conf" - } - ], - "coreId": "m55_he", - "debug": { - "console": "uart", - "probe": "openocd" - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": "arm-zephyr-eabi-gcc", - "id": "arm-zephyr-eabi", - "sysroot": null, - "targetTriple": "arm-zephyr-eabi" - } - }, - { - "appDir": "/examples/connectivity/iot-fleet-ota/src", - "artifacts": { - "bin": "build/m55_hp-zephyr/zephyr/zephyr.bin", - "compileCommands": "build/m55_hp-zephyr/compile_commands.json", - "elf": "build/m55_hp-zephyr/zephyr/zephyr.elf", - "map": "build/m55_hp-zephyr/zephyr/zephyr.map", - "sizeReport": "build/m55_hp-zephyr/zephyr/zephyr.stat", - "symbols": "build/m55_hp-zephyr/zephyr/zephyr.symbols" - }, - "backend": "zephyr", - "buildDir": "build/m55_hp-zephyr", - "command": { - "args": [ - "build", - "-b", - "alp_e1m_aen801_m55_hp/ae822fa0e5597ls0/rtss_hp", - "/examples/connectivity/iot-fleet-ota", - "--sysbuild", - "--", - "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14", - "-DSB_CONF_FILE=/zephyr/sysbuild/aen/sysbuild.conf;/examples/connectivity/iot-fleet-ota/build/alp_sysbuild.conf" - ], - "cwd": "build/m55_hp-zephyr", - "tool": "west" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_hp` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_hp` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_SPI=y\n\n# IoT features declared on core `m55_hp` (board.yaml `cores.*.iot`)\n# Wireless provider resolved from `E1M-AEN801` on_module.wifi_ble: cc3501e.\n# Wi-Fi: AEN CC3501E bridge backend, not Zephyr wifi_mgmt.\nCONFIG_ALP_SDK_WIFI_CC3501E=y\n# TLS: credential store + TLS-capable protocol clients.\nCONFIG_TLS_CREDENTIALS=y\n\n# Libraries declared on core `m55_hp`\nCONFIG_MBEDTLS=y\nCONFIG_MBEDTLS_BUILTIN=y\nCONFIG_ALP_MBEDTLS_PURE_C=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n# OTA Zephyr client (board.yaml `ota.provider: mender`)\n# Mender-MCU-client wiring is pending the v0.7 OTA module\n# (mender-mcu-client west group activation).\n# CONFIG_MENDER_MCU_CLIENT=y\n# CONFIG_MENDER_SERVER_URL=\"https://hosted.mender.io\"\n# CONFIG_MENDER_TENANT_TOKEN=\"${MENDER_TENANT_TOKEN}\"\n# CONFIG_MENDER_ARTIFACT_NAME=\"iot-fleet-ota\"\n# CONFIG_MENDER_UPDATE_POLL_INTERVAL=1800\n\n", - "path": "build/m55_hp-zephyr/alp.conf" - } - ], - "coreId": "m55_hp", - "debug": { - "console": "uart", - "probe": "openocd" - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": "arm-zephyr-eabi-gcc", - "id": "arm-zephyr-eabi", - "sysroot": null, - "targetTriple": "arm-zephyr-eabi" - } - } - ], - "warnings": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[multicore_heterogeneous-offload.build-plan]#0": [ - 0, - { - "command": "build", - "data": { - "boardYaml": "examples/multicore/heterogeneous-offload/board.yaml", - "buildRoot": "build", - "executionPolicy": { - "missingTool": "skip", - "nullCommand": "skip", - "unknownBackend": "fail" - }, - "generatedBy": "scripts/alp_orchestrate.py", - "schemaVersion": 1, - "sdkCommit": "97ad481b", - "sdkVersion": "0.11.1", - "sharedArtefacts": [ - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-V2N101\"\n\n/* rpmsg channel 'alp_default_rpmsg' -- endpoints a55_cluster, m33_sm */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_NAME \"alp_default_rpmsg\"\n#define ALP_IPC_ALP_DEFAULT_RPMSG_ADDR 0x00010000u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SIZE 0x00080000u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT 0x000004e6u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_DST_EPT 0x000004e7u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_MBOX_CH 0u\n\n#endif /* ALP_SYSTEM_IPC_H */\n", - "path": "build/generated/alp/system_ipc.h" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n alp_default_rpmsg: alp_default_rpmsg@10000 {\n compatible = \"shared-dma-pool\";\n reg = <0x0 0x00010000 0x0 0x00080000>;\n no-map;\n label = \"alp_default_rpmsg\";\n };\n\n };\n};\n", - "path": "build/generated/dts-reservations.dtsi" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", - "path": "build/generated/dts-partitions.dtsi" - } - ], - "sku": "E1M-V2N101", - "slices": [ - { - "appDir": "/examples/multicore/heterogeneous-offload/linux", - "artifacts": { - "bin": null, - "compileCommands": null, - "elf": null, - "map": null, - "sizeReport": null, - "symbols": null - }, - "backend": "yocto", - "buildDir": "build/a55_cluster-yocto", - "command": { - "args": [ - "alp-image-edge" - ], - "cwd": "build/a55_cluster-yocto", - "tool": "bitbake" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- append to local.conf.\n# Per-core slice `a55_cluster` (image: alp-image-edge)\n# Console: Linux console (kernel `console=` cmdline -> SoM debug UART; userspace -> stdout/tty).\nMACHINE = \"e1m-v2n101-a55\"\nIMAGE_INSTALL:append = \" lib-mbedtls lib-nlohmann-json\"\n# bitbake target: alp-image-edge\n", - "path": "build/a55_cluster-yocto/local.conf" - } - ], - "coreId": "a55_cluster", - "debug": { - "console": "linux", - "probe": null - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": null, - "id": "poky-glibc", - "sysroot": null, - "targetTriple": null - } - }, - { - "appDir": "/examples/multicore/heterogeneous-offload/m33_sm", - "artifacts": { - "bin": "build/m33_sm-zephyr/zephyr/zephyr.bin", - "compileCommands": "build/m33_sm-zephyr/compile_commands.json", - "elf": "build/m33_sm-zephyr/zephyr/zephyr.elf", - "map": "build/m33_sm-zephyr/zephyr/zephyr.map", - "sizeReport": "build/m33_sm-zephyr/zephyr/zephyr.stat", - "symbols": "build/m33_sm-zephyr/zephyr/zephyr.symbols" - }, - "backend": "zephyr", - "buildDir": "build/m33_sm-zephyr", - "command": { - "args": [ - "build", - "-b", - "alp_e1m_v2n101_m33_sm/r9a09g056n48gbg/cm33", - "/examples/multicore/heterogeneous-offload/m33_sm", - "--", - "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" - ], - "cwd": "build/m33_sm-zephyr", - "tool": "west" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m33_sm` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (renesas:rzv2n:n44 via E1M-V2N101)\nCONFIG_ALP_SOC_RENESAS_RZV2N_N44=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_X_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-V2N101` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_ACT8760=y\nCONFIG_ALP_SDK_CHIP_CLK_5L35023B=y\nCONFIG_ALP_SDK_CHIP_DA9292=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_GD32G553=y\nCONFIG_ALP_SDK_CHIP_MURATA_LBEE5HY2FY=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RTL8211FDI=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=n\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_LSM6DSO=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1306=n\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m33_sm` (chip drivers + peripherals)\nCONFIG_ADC=y\nCONFIG_GPIO=y\nCONFIG_I2C=y\n\n# Libraries declared on core `m33_sm`\nCONFIG_CMSIS_DSP=y\nCONFIG_CMSIS_DSP_BASICMATH=y\nCONFIG_CMSIS_DSP_COMPLEXMATH=y\nCONFIG_CMSIS_DSP_CONTROLLER=y\nCONFIG_CMSIS_DSP_FASTMATH=y\nCONFIG_CMSIS_DSP_FILTERING=y\nCONFIG_CMSIS_DSP_INTERPOLATION=y\nCONFIG_CMSIS_DSP_MATRIX=y\nCONFIG_CMSIS_DSP_STATISTICS=y\nCONFIG_CMSIS_DSP_SUPPORT=y\nCONFIG_CMSIS_DSP_TRANSFORM=y\nCONFIG_ALP_CMSIS_DSP_SCALAR=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_REF=y\n\n", - "path": "build/m33_sm-zephyr/alp.conf" - } - ], - "coreId": "m33_sm", - "debug": { - "console": null, - "probe": "openocd" - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": "arm-zephyr-eabi-gcc", - "id": "arm-zephyr-eabi", - "sysroot": null, - "targetTriple": "arm-zephyr-eabi" - } - } - ], - "warnings": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[multicore_rpmsg-aen.build-plan]#0": [ - 0, - { - "command": "build", - "data": { - "boardYaml": "examples/multicore/rpmsg-aen/board.yaml", - "buildRoot": "build", - "executionPolicy": { - "missingTool": "skip", - "nullCommand": "skip", - "unknownBackend": "fail" - }, - "generatedBy": "scripts/alp_orchestrate.py", - "schemaVersion": 1, - "sdkCommit": "97ad481b", - "sdkVersion": "0.11.1", - "sharedArtefacts": [ - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-AEN801\"\n\n/* rpmsg channel 'alp_default_rpmsg' -- endpoints a32_cluster, m55_hp */\n/* BLOCKED: memory_map.base is unset for region 'sram0' in SoM E1M-AEN801; this SoM hasn't been HW-mapped yet so IPC carve-outs cannot be allocated. Add a `memory_map:` block to metadata/e1m_modules/E1M-AEN801.yaml (or per-region `base`) or remove the matching ipc entry from board.yaml. */\n/* IPC channel 'alp_default_rpmsg' is blocked; fix the SoM metadata before depending on this channel at runtime. */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_NAME \"alp_default_rpmsg\"\n#define ALP_IPC_ALP_DEFAULT_RPMSG_ADDR 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SIZE 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_DST_EPT 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_MBOX_CH 0u /* stub: blocked */\n\n#endif /* ALP_SYSTEM_IPC_H */\n", - "path": "build/generated/alp/system_ipc.h" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n /* BLOCKED: alp_default_rpmsg -- memory_map.base is unset for region 'sram0' in SoM E1M-AEN801; this SoM hasn't been HW-mapped yet so IPC carve-outs cannot be allocated. Add a `memory_map:` block to metadata/e1m_modules/E1M-AEN801.yaml (or per-region `base`) or remove the matching ipc entry from board.yaml. */\n\n };\n};\n", - "path": "build/generated/dts-reservations.dtsi" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", - "path": "build/generated/dts-partitions.dtsi" - } - ], - "sku": "E1M-AEN801", - "slices": [ - { - "appDir": "/examples/multicore/rpmsg-aen/linux", - "artifacts": { - "bin": null, - "compileCommands": null, - "elf": null, - "map": null, - "sizeReport": null, - "symbols": null - }, - "backend": "yocto", - "buildDir": "build/a32_cluster-yocto", - "command": { - "args": [ - "alp-image-edge" - ], - "cwd": "build/a32_cluster-yocto", - "tool": "bitbake" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- append to local.conf.\n# Per-core slice `a32_cluster` (image: alp-image-edge)\n# Console: Linux console (kernel `console=` cmdline -> SoM debug UART; userspace -> stdout/tty).\nMACHINE = \"e1m-aen801-a32\"\nIMAGE_INSTALL:append = \" lib-mbedtls lib-nlohmann-json\"\n# bitbake target: alp-image-edge\n", - "path": "build/a32_cluster-yocto/local.conf" - } - ], - "coreId": "a32_cluster", - "debug": { - "console": "linux", - "probe": null - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": null, - "id": "poky-glibc", - "sysroot": null, - "targetTriple": null - } - }, - { - "appDir": "/firmware/alp-stock-shim", - "artifacts": { - "bin": "build/m55_he-zephyr/zephyr/zephyr.bin", - "compileCommands": "build/m55_he-zephyr/compile_commands.json", - "elf": "build/m55_he-zephyr/zephyr/zephyr.elf", - "map": "build/m55_he-zephyr/zephyr/zephyr.map", - "sizeReport": "build/m55_he-zephyr/zephyr/zephyr.stat", - "symbols": "build/m55_he-zephyr/zephyr/zephyr.symbols" - }, - "backend": "zephyr", - "buildDir": "build/m55_he-zephyr", - "command": { - "args": [ - "build", - "-b", - "alp_e1m_aen801_m55_he/ae822fa0e5597ls0/rtss_he", - "/firmware/alp-stock-shim", - "--", - "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" - ], - "cwd": "build/m55_he-zephyr", - "tool": "west" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_he` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_he` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_SPI=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n", - "path": "build/m55_he-zephyr/alp.conf" - } - ], - "coreId": "m55_he", - "debug": { - "console": "uart", - "probe": "openocd" - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": "arm-zephyr-eabi-gcc", - "id": "arm-zephyr-eabi", - "sysroot": null, - "targetTriple": "arm-zephyr-eabi" - } - }, - { - "appDir": "/examples/multicore/rpmsg-aen/m55_hp", - "artifacts": { - "bin": "build/m55_hp-zephyr/zephyr/zephyr.bin", - "compileCommands": "build/m55_hp-zephyr/compile_commands.json", - "elf": "build/m55_hp-zephyr/zephyr/zephyr.elf", - "map": "build/m55_hp-zephyr/zephyr/zephyr.map", - "sizeReport": "build/m55_hp-zephyr/zephyr/zephyr.stat", - "symbols": "build/m55_hp-zephyr/zephyr/zephyr.symbols" - }, - "backend": "zephyr", - "buildDir": "build/m55_hp-zephyr", - "command": { - "args": [ - "build", - "-b", - "alp_e1m_aen801_m55_hp/ae822fa0e5597ls0/rtss_hp", - "/examples/multicore/rpmsg-aen/m55_hp", - "--", - "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" - ], - "cwd": "build/m55_hp-zephyr", - "tool": "west" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_hp` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_hp` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_SPI=y\n\n# Libraries declared on core `m55_hp`\nCONFIG_CMSIS_DSP=y\nCONFIG_CMSIS_DSP_BASICMATH=y\nCONFIG_CMSIS_DSP_COMPLEXMATH=y\nCONFIG_CMSIS_DSP_CONTROLLER=y\nCONFIG_CMSIS_DSP_FASTMATH=y\nCONFIG_CMSIS_DSP_FILTERING=y\nCONFIG_CMSIS_DSP_INTERPOLATION=y\nCONFIG_CMSIS_DSP_MATRIX=y\nCONFIG_CMSIS_DSP_STATISTICS=y\nCONFIG_CMSIS_DSP_SUPPORT=y\nCONFIG_CMSIS_DSP_TRANSFORM=y\nCONFIG_ALP_CMSIS_DSP_SCALAR=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n", - "path": "build/m55_hp-zephyr/alp.conf" - } - ], - "coreId": "m55_hp", - "debug": { - "console": "uart", - "probe": "openocd" - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": "arm-zephyr-eabi-gcc", - "id": "arm-zephyr-eabi", - "sysroot": null, - "targetTriple": "arm-zephyr-eabi" - } - } - ], - "warnings": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[multicore_rpmsg-imx93.build-plan]#0": [ - 0, - { - "command": "build", - "data": { - "boardYaml": "examples/multicore/rpmsg-imx93/board.yaml", - "buildRoot": "build", - "executionPolicy": { - "missingTool": "skip", - "nullCommand": "skip", - "unknownBackend": "fail" - }, - "generatedBy": "scripts/alp_orchestrate.py", - "schemaVersion": 1, - "sdkCommit": "97ad481b", - "sdkVersion": "0.11.1", - "sharedArtefacts": [ - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-NX9101\"\n\n/* rpmsg channel 'alp_default_rpmsg' -- endpoints a55_cluster, m33 */\n/* BLOCKED: SoM E1M-NX9101 mailbox controller is TBD; carve-out resolution requires authoritative mailbox metadata. Fill `mailbox.controller:` in metadata/e1m_modules/E1M-NX9101.yaml with the vendor mailbox node name (e.g. `renesas_mhu`, `nxp_mu`, `alif_mhuv2`) or remove the rpmsg entries from board.yaml. */\n/* IPC channel 'alp_default_rpmsg' is blocked; fix the SoM metadata before depending on this channel at runtime. */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_NAME \"alp_default_rpmsg\"\n#define ALP_IPC_ALP_DEFAULT_RPMSG_ADDR 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SIZE 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_DST_EPT 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_MBOX_CH 0u /* stub: blocked */\n\n#endif /* ALP_SYSTEM_IPC_H */\n", - "path": "build/generated/alp/system_ipc.h" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n /* BLOCKED: alp_default_rpmsg -- SoM E1M-NX9101 mailbox controller is TBD; carve-out resolution requires authoritative mailbox metadata. Fill `mailbox.controller:` in metadata/e1m_modules/E1M-NX9101.yaml with the vendor mailbox node name (e.g. `renesas_mhu`, `nxp_mu`, `alif_mhuv2`) or remove the rpmsg entries from board.yaml. */\n\n };\n};\n", - "path": "build/generated/dts-reservations.dtsi" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", - "path": "build/generated/dts-partitions.dtsi" - } - ], - "sku": "E1M-NX9101", - "slices": [ - { - "appDir": "/examples/multicore/rpmsg-imx93/linux", - "artifacts": { - "bin": null, - "compileCommands": null, - "elf": null, - "map": null, - "sizeReport": null, - "symbols": null - }, - "backend": "yocto", - "buildDir": "build/a55_cluster-yocto", - "command": { - "args": [ - "alp-image-edge" - ], - "cwd": "build/a55_cluster-yocto", - "tool": "bitbake" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- append to local.conf.\n# Per-core slice `a55_cluster` (image: alp-image-edge)\n# Console: Linux console (kernel `console=` cmdline -> SoM debug UART; userspace -> stdout/tty).\nMACHINE = \"e1m-nx9101-a55\"\nIMAGE_INSTALL:append = \" lib-mbedtls lib-nlohmann-json\"\n# bitbake target: alp-image-edge\n", - "path": "build/a55_cluster-yocto/local.conf" - } - ], - "coreId": "a55_cluster", - "debug": { - "console": "linux", - "probe": null - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": null, - "id": "poky-glibc", - "sysroot": null, - "targetTriple": null - } - }, - { - "appDir": "/examples/multicore/rpmsg-imx93/m33", - "artifacts": { - "bin": "build/m33-zephyr/zephyr/zephyr.bin", - "compileCommands": "build/m33-zephyr/compile_commands.json", - "elf": "build/m33-zephyr/zephyr/zephyr.elf", - "map": "build/m33-zephyr/zephyr/zephyr.map", - "sizeReport": "build/m33-zephyr/zephyr/zephyr.stat", - "symbols": "build/m33-zephyr/zephyr/zephyr.symbols" - }, - "backend": "zephyr", - "buildDir": "build/m33-zephyr", - "command": null, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m33` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# SoM silicon (nxp:imx9:imx93 via E1M-NX9101)\nCONFIG_ALP_SOC_NXP_IMX9_IMX93=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-NX9101` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_PCA9451A=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m33` (chip drivers + peripherals)\nCONFIG_ADC=y\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_PWM=y\n\n# Libraries declared on core `m33`\nCONFIG_CMSIS_DSP=y\nCONFIG_CMSIS_DSP_BASICMATH=y\nCONFIG_CMSIS_DSP_COMPLEXMATH=y\nCONFIG_CMSIS_DSP_CONTROLLER=y\nCONFIG_CMSIS_DSP_FASTMATH=y\nCONFIG_CMSIS_DSP_FILTERING=y\nCONFIG_CMSIS_DSP_INTERPOLATION=y\nCONFIG_CMSIS_DSP_MATRIX=y\nCONFIG_CMSIS_DSP_STATISTICS=y\nCONFIG_CMSIS_DSP_SUPPORT=y\nCONFIG_CMSIS_DSP_TRANSFORM=y\nCONFIG_ALP_CMSIS_DSP_SCALAR=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_REF=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_N93=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U65=y\n\n", - "path": "build/m33-zephyr/alp.conf" - } - ], - "coreId": "m33", - "debug": { - "console": "uart", - "probe": "openocd" - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": "arm-zephyr-eabi-gcc", - "id": "arm-zephyr-eabi", - "sysroot": null, - "targetTriple": "arm-zephyr-eabi" - } - } - ], - "warnings": [ - { - "code": "board-tree-missing", - "coreId": "m33", - "message": "SoM 'E1M-NX9101' core 'm33' wants Zephyr board 'alp_e1m_nx9101_m33', which has no tree under zephyr/boards/alp/ -- board bring-up for this target has not happened yet." - } - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[multicore_rpmsg-v2n.build-plan]#0": [ - 0, - { - "command": "build", - "data": { - "boardYaml": "examples/multicore/rpmsg-v2n/board.yaml", - "buildRoot": "build", - "executionPolicy": { - "missingTool": "skip", - "nullCommand": "skip", - "unknownBackend": "fail" - }, - "generatedBy": "scripts/alp_orchestrate.py", - "schemaVersion": 1, - "sdkCommit": "97ad481b", - "sdkVersion": "0.11.1", - "sharedArtefacts": [ - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-V2N101\"\n\n/* rpmsg channel 'alp_default_rpmsg' -- endpoints a55_cluster, m33_sm */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_NAME \"alp_default_rpmsg\"\n#define ALP_IPC_ALP_DEFAULT_RPMSG_ADDR 0x00010000u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SIZE 0x00080000u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT 0x000004e6u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_DST_EPT 0x000004e7u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_MBOX_CH 0u\n\n#endif /* ALP_SYSTEM_IPC_H */\n", - "path": "build/generated/alp/system_ipc.h" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n alp_default_rpmsg: alp_default_rpmsg@10000 {\n compatible = \"shared-dma-pool\";\n reg = <0x0 0x00010000 0x0 0x00080000>;\n no-map;\n label = \"alp_default_rpmsg\";\n };\n\n };\n};\n", - "path": "build/generated/dts-reservations.dtsi" - }, - { - "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", - "path": "build/generated/dts-partitions.dtsi" - } - ], - "sku": "E1M-V2N101", - "slices": [ - { - "appDir": "/examples/multicore/rpmsg-v2n/linux", - "artifacts": { - "bin": null, - "compileCommands": null, - "elf": null, - "map": null, - "sizeReport": null, - "symbols": null - }, - "backend": "yocto", - "buildDir": "build/a55_cluster-yocto", - "command": { - "args": [ - "alp-image-edge" - ], - "cwd": "build/a55_cluster-yocto", - "tool": "bitbake" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- append to local.conf.\n# Per-core slice `a55_cluster` (image: alp-image-edge)\n# Console: Linux console (kernel `console=` cmdline -> SoM debug UART; userspace -> stdout/tty).\nMACHINE = \"e1m-v2n101-a55\"\n# Peripherals declared for this Yocto slice are BSP/kernel-owned (emmc, ethernet, usb); no Zephyr Kconfig or local.conf package knob is emitted here.\n\n# IoT features declared on core `a55_cluster` (board.yaml `cores.*.iot`)\n# Wireless provider resolved from `E1M-V2N101` on_module.wifi_ble: murata_lbee5hy2fy.\n# Wi-Fi: Linux owns this provider's SDIO/firmware path; BSP/machine recipes supply kernel/firmware packages.\nIMAGE_INSTALL:append = \" wpa-supplicant iw wireless-regdb ca-certificates\"\nPACKAGECONFIG:append:pn-alp-sdk = \" mqtt security\"\nIMAGE_INSTALL:append = \" lib-mbedtls lib-nlohmann-json\"\n# bitbake target: alp-image-edge\n", - "path": "build/a55_cluster-yocto/local.conf" - } - ], - "coreId": "a55_cluster", - "debug": { - "console": "linux", - "probe": null - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": null, - "id": "poky-glibc", - "sysroot": null, - "targetTriple": null - } - }, - { - "appDir": "/examples/multicore/rpmsg-v2n/m33_sm", - "artifacts": { - "bin": "build/m33_sm-zephyr/zephyr/zephyr.bin", - "compileCommands": "build/m33_sm-zephyr/compile_commands.json", - "elf": "build/m33_sm-zephyr/zephyr/zephyr.elf", - "map": "build/m33_sm-zephyr/zephyr/zephyr.map", - "sizeReport": "build/m33_sm-zephyr/zephyr/zephyr.stat", - "symbols": "build/m33_sm-zephyr/zephyr/zephyr.symbols" - }, - "backend": "zephyr", - "buildDir": "build/m33_sm-zephyr", - "command": { - "args": [ - "build", - "-b", - "alp_e1m_v2n101_m33_sm/r9a09g056n48gbg/cm33", - "/examples/multicore/rpmsg-v2n/m33_sm", - "--", - "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" - ], - "cwd": "build/m33_sm-zephyr", - "tool": "west" - }, - "configArtefacts": [ - { - "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m33_sm` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (renesas:rzv2n:n44 via E1M-V2N101)\nCONFIG_ALP_SOC_RENESAS_RZV2N_N44=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_X_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-V2N101` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_ACT8760=y\nCONFIG_ALP_SDK_CHIP_CLK_5L35023B=y\nCONFIG_ALP_SDK_CHIP_DA9292=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_GD32G553=y\nCONFIG_ALP_SDK_CHIP_MURATA_LBEE5HY2FY=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RTL8211FDI=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=n\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_LSM6DSO=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1306=n\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m33_sm` (chip drivers + peripherals)\nCONFIG_ADC=y\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_PWM=y\n\n# Libraries declared on core `m33_sm`\nCONFIG_CMSIS_DSP=y\nCONFIG_CMSIS_DSP_BASICMATH=y\nCONFIG_CMSIS_DSP_COMPLEXMATH=y\nCONFIG_CMSIS_DSP_CONTROLLER=y\nCONFIG_CMSIS_DSP_FASTMATH=y\nCONFIG_CMSIS_DSP_FILTERING=y\nCONFIG_CMSIS_DSP_INTERPOLATION=y\nCONFIG_CMSIS_DSP_MATRIX=y\nCONFIG_CMSIS_DSP_STATISTICS=y\nCONFIG_CMSIS_DSP_SUPPORT=y\nCONFIG_CMSIS_DSP_TRANSFORM=y\nCONFIG_ALP_CMSIS_DSP_SCALAR=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_REF=y\n\n", - "path": "build/m33_sm-zephyr/alp.conf" - } - ], - "coreId": "m33_sm", - "debug": { - "console": null, - "probe": "openocd" - }, - "env": { - "ALP_SDK_ROOT": "" - }, - "envAppendPath": { - "EXTRA_ZEPHYR_MODULES": [ - "" - ], - "PYTHONPATH": [ - "/scripts" - ] - }, - "toolchain": { - "compiler": "arm-zephyr-eabi-gcc", - "id": "arm-zephyr-eabi", - "sysroot": null, - "targetTriple": "arm-zephyr-eabi" - } - } - ], - "warnings": [] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_plan_from_with_materialise_writes_every_artefact#0": [ - 0, - { - "command": "build", - "data": { - "baseDir": "", - "schemaVersion": "1", - "written": [ - "build/generated/alp/system_ipc.h", - "build/generated/dts-reservations.dtsi", - "build/generated/dts-partitions.dtsi", - "build/a55_cluster-yocto/local.conf", - "build/m33_sm-zephyr/alp.conf" - ] - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_python_matches_rust[--format json debug-config --target-kind bogus]#0": [ - 5, - { - "command": "debug-config", - "data": { - "configuration": null, - "generatedAt": "1970-01-01T00:00:00.000Z", - "launchJsonPath": "\\.vscode\\launch.json", - "notes": [], - "preview": false, - "replaced": false, - "schemaVersion": "1", - "server": "none", - "targetKind": "zephyr-mcu" - }, - "exitCode": 5, - "issues": [ - { - "code": "debug-config.internal-failure", - "message": "Unsupported --target-kind 'bogus'. Allowed values: zephyr-mcu, baremetal-mcu, yocto-userspace, native-host.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": null - } - } - ], - "tests/parity/test_oracle_parity.py::test_python_matches_rust[--format json debug-config --target-kind native-host --preview]#0": [ - 0, - { - "command": "debug-config", - "data": { - "configuration": { - "cwd": "${workspaceFolder}", - "name": "Alp: Native Sim Debug", - "program": "${workspaceFolder}/build/native_sim/zephyr/zephyr.exe", - "request": "launch", - "type": "lldb" - }, - "generatedAt": "1970-01-01T00:00:00.000Z", - "launchJsonPath": "\\.vscode\\launch.json", - "notes": [ - "This is a draft launch configuration generated by tan.", - "The long-term target is to resolve these values from the shared debug model." - ], - "preview": true, - "replaced": false, - "schemaVersion": "1", - "server": "none", - "targetKind": "native-host" - }, - "exitCode": 0, - "issues": [], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_python_matches_rust[--version]#0": [ - 0, - { - "__raw__": "tan 0.4.1" - } - ], - "tests/parity/test_oracle_parity.py::test_python_matches_rust[]#0": [ - 2, - { - "__raw__": "" - } - ], - "tests/parity/test_oracle_parity.py::test_python_matches_rust[bogus-command]#0": [ - 2, - { - "__raw__": "" - } - ], - "tests/parity/test_oracle_parity.py::test_python_matches_rust[build --plan --format json]#0": [ - 1, - { - "command": "build", - "data": null, - "exitCode": 1, - "issues": [ - { - "code": "build.plan-unavailable", - "message": "no alp-sdk checkout found \u2014 pass `--sdk-root `, pin one with `tan sdk switch `, set it in settings, or run `tan bootstrap`. The build-plan comes from the SDK's `alp_orchestrate --emit build-plan`.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_python_matches_rust[clean --format json]#0": [ - 1, - { - "command": "clean", - "data": { - "buildRoot": "", - "dryRun": false, - "removed": 0, - "targets": [] - }, - "exitCode": 1, - "issues": [ - { - "code": "clean.sdk-root-not-found", - "message": "Cannot locate alp-sdk root.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_python_matches_rust[presets --format json]#0": [ - 0, - { - "command": "presets", - "data": { - "boardLibraries": [], - "inferenceBackends": [ - "auto", - "cpu", - "ethos_u", - "drpai", - "deepx_dxm1" - ], - "libraries": [ - "etl", - "fmt", - "nlohmann_json", - "doctest", - "lvgl", - "mbedtls", - "cmsis_dsp", - "littlefs" - ], - "logLevels": [ - "error", - "warn", - "info", - "debug", - "trace" - ], - "osChoices": [ - "zephyr", - "yocto", - "baremetal" - ], - "schemaVersion": "1", - "sdkRoot": null, - "skus": [], - "soms": [] - }, - "exitCode": 0, - "issues": [ - { - "code": "presets.sdk-root-unresolved", - "message": "alp-sdk root is unresolved. Returning built-in defaults and empty SDK preset lists.", - "severity": "warning" - } - ], - "ok": true, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_python_matches_rust[validate --format json]#0": [ - 2, - { - "command": "validate", - "data": { - "boardYamlPath": "/board.yaml", - "commandLine": "", - "issueCount": 1, - "outcome": "failed", - "schemaVersion": "1" - }, - "exitCode": 2, - "issues": [ - { - "code": "validate.board-yaml-missing", - "message": "board.yaml path could not be resolved or the file does not exist.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_sdk_switch_unresolvable_version_is_a_known_divergence_from_the_oracle#0": [ - 1, - { - "command": "sdk", - "data": { - "scope": "project", - "sdkPath": "\\.alp\\sdk-cache\\9.9.9-does-not-exist", - "subcommand": "switch", - "version": null - }, - "exitCode": 1, - "issues": [ - { - "code": "sdk.path-not-found", - "message": "SDK path not found: \\.alp\\sdk-cache\\9.9.9-does-not-exist", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": null - } - } - ], - "tests/parity/test_oracle_parity.py::test_validate_board_yaml_missing_guard_matches_the_oracle_at_exit_2#0": [ - 2, - { - "command": "validate", - "data": { - "boardYamlPath": "/board.yaml", - "commandLine": "", - "issueCount": 1, - "outcome": "failed", - "schemaVersion": "1" - }, - "exitCode": 2, - "issues": [ - { - "code": "validate.board-yaml-missing", - "message": "board.yaml path could not be resolved or the file does not exist.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_validate_no_sdk_guard_is_a_known_divergence_from_the_oracle#0": [ - 2, - { - "command": "validate", - "data": { - "boardYamlPath": "/board.yaml", - "commandLine": "", - "issueCount": 1, - "outcome": "failed", - "schemaVersion": "1" - }, - "exitCode": 2, - "issues": [ - { - "code": "validate.sdk-root-unresolved", - "message": "alp-sdk root is unresolved. Use --sdk-root or place project near alp-sdk checkout.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": "/board.yaml", - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_west_forward_matches_rust[lock]#0": [ - 1, - { - "command": "lock", - "data": { - "args": [ - "--core", - "m55_hp", - "-b", - "some_board" - ], - "schemaVersion": "1", - "westCommand": "alp-lock", - "westCwd": "" - }, - "exitCode": 1, - "issues": [ - { - "code": "lock.failed", - "message": "west not found on PATH \u2014 run `tan bootstrap` and ensure west is on PATH.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_west_forward_matches_rust[migrate]#0": [ - 1, - { - "command": "migrate", - "data": { - "args": [ - "--core", - "m55_hp", - "-b", - "some_board" - ], - "schemaVersion": "1", - "westCommand": "alp-migrate", - "westCwd": "" - }, - "exitCode": 1, - "issues": [ - { - "code": "migrate.failed", - "message": "west not found on PATH \u2014 run `tan bootstrap` and ensure west is on PATH.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_oracle_parity.py::test_west_forward_matches_rust[quality]#0": [ - 1, - { - "command": "quality", - "data": { - "args": [ - "--core", - "m55_hp", - "-b", - "some_board" - ], - "schemaVersion": "1", - "westCommand": "alp-quality", - "westCwd": "" - }, - "exitCode": 1, - "issues": [ - { - "code": "quality.failed", - "message": "west not found on PATH \u2014 run `tan bootstrap` and ensure west is on PATH.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - } - ] -} +{ + "tests/parity/test_oracle_parity.py::test_debug_config_native_host_preview_global_format_matches_rust#0": [ + 0, + { + "command": "debug-config", + "data": { + "configuration": { + "cwd": "${workspaceFolder}", + "name": "Alp: Native Sim Debug", + "program": "${workspaceFolder}/build/native_sim/zephyr/zephyr.exe", + "request": "launch", + "type": "lldb" + }, + "generatedAt": "1970-01-01T00:00:00.000Z", + "launchJsonPath": "\\.vscode\\launch.json", + "notes": [ + "This is a draft launch configuration generated by tan.", + "The long-term target is to resolve these values from the shared debug model." + ], + "preview": true, + "replaced": false, + "schemaVersion": "1", + "server": "none", + "targetKind": "native-host" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[native-host-none-alp: build native_sim target]#0": [ + 0, + { + "command": "debug-config", + "data": { + "configuration": { + "cwd": "${workspaceFolder}", + "name": "Alp: Native Sim Debug", + "program": "${workspaceFolder}/build/native_sim-zephyr/build/zephyr/zephyr.exe", + "request": "launch", + "type": "lldb" + }, + "generatedAt": "1970-01-01T00:00:00.000Z", + "launchJsonPath": "\\.vscode\\launch.json", + "notes": [ + "This is a draft launch configuration generated by tan.", + "The long-term target is to resolve these values from the shared debug model." + ], + "preview": true, + "replaced": false, + "schemaVersion": "1", + "server": "none", + "targetKind": "native-host" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[native-host-none]#0": [ + 0, + { + "command": "debug-config", + "data": { + "configuration": { + "cwd": "${workspaceFolder}", + "name": "Alp: Native Sim Debug", + "program": "${workspaceFolder}/build/native_sim-zephyr/build/zephyr/zephyr.exe", + "request": "launch", + "type": "lldb" + }, + "generatedAt": "1970-01-01T00:00:00.000Z", + "launchJsonPath": "\\.vscode\\launch.json", + "notes": [ + "This is a draft launch configuration generated by tan.", + "The long-term target is to resolve these values from the shared debug model." + ], + "preview": true, + "replaced": false, + "schemaVersion": "1", + "server": "none", + "targetKind": "native-host" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[zephyr-mcu-jlink-alp: build active target]#0": [ + 0, + { + "command": "debug-config", + "data": { + "configuration": { + "cwd": "${workspaceFolder}", + "device": "AE822F4M55_HP", + "executable": "${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf", + "gdbPath": "/zephyr-sdk/arm-zephyr-eabi-gdb", + "interface": "swd", + "name": "Alp: Zephyr Debug (J-Link)", + "request": "launch", + "runToEntryPoint": "main", + "servertype": "jlink", + "type": "cortex-debug" + }, + "generatedAt": "1970-01-01T00:00:00.000Z", + "launchJsonPath": "\\.vscode\\launch.json", + "notes": [ + "This is a draft launch configuration generated by tan.", + "The long-term target is to resolve these values from the shared debug model." + ], + "preview": true, + "replaced": false, + "schemaVersion": "1", + "server": "jlink", + "targetKind": "zephyr-mcu" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[zephyr-mcu-jlink]#0": [ + 0, + { + "command": "debug-config", + "data": { + "configuration": { + "cwd": "${workspaceFolder}", + "device": "AE822F4M55_HP", + "executable": "${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf", + "gdbPath": "/zephyr-sdk/arm-zephyr-eabi-gdb", + "interface": "swd", + "name": "Alp: Zephyr Debug (J-Link)", + "request": "launch", + "runToEntryPoint": "main", + "servertype": "jlink", + "type": "cortex-debug" + }, + "generatedAt": "1970-01-01T00:00:00.000Z", + "launchJsonPath": "\\.vscode\\launch.json", + "notes": [ + "This is a draft launch configuration generated by tan.", + "The long-term target is to resolve these values from the shared debug model." + ], + "preview": true, + "replaced": false, + "schemaVersion": "1", + "server": "jlink", + "targetKind": "zephyr-mcu" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[zephyr-mcu-openocd-alp: build active target]#0": [ + 0, + { + "command": "debug-config", + "data": { + "configuration": { + "configFiles": [ + "board/alp.cfg" + ], + "cwd": "${workspaceFolder}", + "executable": "${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf", + "gdbPath": "/zephyr-sdk/arm-zephyr-eabi-gdb", + "name": "Alp: Zephyr Debug (OpenOCD)", + "request": "launch", + "runToEntryPoint": "main", + "searchDir": [ + "/usr/share/openocd/scripts" + ], + "serverpath": "/usr/bin/openocd", + "servertype": "openocd", + "type": "cortex-debug" + }, + "generatedAt": "1970-01-01T00:00:00.000Z", + "launchJsonPath": "\\.vscode\\launch.json", + "notes": [ + "This is a draft launch configuration generated by tan.", + "The long-term target is to resolve these values from the shared debug model." + ], + "preview": true, + "replaced": false, + "schemaVersion": "1", + "server": "openocd", + "targetKind": "zephyr-mcu" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[zephyr-mcu-openocd]#0": [ + 0, + { + "command": "debug-config", + "data": { + "configuration": { + "configFiles": [ + "board/alp.cfg" + ], + "cwd": "${workspaceFolder}", + "executable": "${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf", + "gdbPath": "/zephyr-sdk/arm-zephyr-eabi-gdb", + "name": "Alp: Zephyr Debug (OpenOCD)", + "request": "launch", + "runToEntryPoint": "main", + "searchDir": [ + "/usr/share/openocd/scripts" + ], + "serverpath": "/usr/bin/openocd", + "servertype": "openocd", + "type": "cortex-debug" + }, + "generatedAt": "1970-01-01T00:00:00.000Z", + "launchJsonPath": "\\.vscode\\launch.json", + "notes": [ + "This is a draft launch configuration generated by tan.", + "The long-term target is to resolve these values from the shared debug model." + ], + "preview": true, + "replaced": false, + "schemaVersion": "1", + "server": "openocd", + "targetKind": "zephyr-mcu" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[zephyr-mcu-pyocd-alp: build active target]#0": [ + 0, + { + "command": "debug-config", + "data": { + "configuration": { + "cwd": "${workspaceFolder}", + "executable": "${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf", + "gdbPath": "/zephyr-sdk/arm-zephyr-eabi-gdb", + "name": "Alp: Zephyr Debug (pyOCD)", + "request": "launch", + "runToEntryPoint": "main", + "servertype": "pyocd", + "targetId": "", + "type": "cortex-debug" + }, + "generatedAt": "1970-01-01T00:00:00.000Z", + "launchJsonPath": "\\.vscode\\launch.json", + "notes": [ + "This is a draft launch configuration generated by tan.", + "Placeholder fields such as still need project-specific resolution.", + "The long-term target is to resolve these values from the shared debug model.", + "This build registers no 'pyocd' runner (runners.yaml: [\"jlink\", \"openocd\"]), so its fields could not be resolved." + ], + "preview": true, + "replaced": false, + "schemaVersion": "1", + "server": "pyocd", + "targetKind": "zephyr-mcu" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_debug_config_resolution_matches_rust[zephyr-mcu-pyocd]#0": [ + 0, + { + "command": "debug-config", + "data": { + "configuration": { + "cwd": "${workspaceFolder}", + "executable": "${workspaceFolder}/build/m55_hp-zephyr/build/zephyr/zephyr.elf", + "gdbPath": "/zephyr-sdk/arm-zephyr-eabi-gdb", + "name": "Alp: Zephyr Debug (pyOCD)", + "request": "launch", + "runToEntryPoint": "main", + "servertype": "pyocd", + "targetId": "", + "type": "cortex-debug" + }, + "generatedAt": "1970-01-01T00:00:00.000Z", + "launchJsonPath": "\\.vscode\\launch.json", + "notes": [ + "This is a draft launch configuration generated by tan.", + "Placeholder fields such as still need project-specific resolution.", + "The long-term target is to resolve these values from the shared debug model.", + "This build registers no 'pyocd' runner (runners.yaml: [\"jlink\", \"openocd\"]), so its fields could not be resolved." + ], + "preview": true, + "replaced": false, + "schemaVersion": "1", + "server": "pyocd", + "targetKind": "zephyr-mcu" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_generate_matches_rust_with_a_resolvable_sdk#0": [ + 0, + { + "__raw__": "" + } + ], + "tests/parity/test_oracle_parity.py::test_generate_matches_rust_with_a_resolvable_sdk#1": [ + 0, + { + "command": "generate", + "data": { + "failed": [], + "schemaVersion": "1", + "targets": [ + "zephyr-conf", + "dts-overlay", + "native-sim-overlay", + "cmake-args", + "yocto-conf", + "carrier-netlist", + "west-libraries", + "hw-info-h", + "os-topology" + ], + "written": [ + "build\\generated\\alp.conf", + "build\\generated\\alp.overlay", + "boards\\native_sim_native_64.overlay", + "build\\generated\\alp-cmake-args.txt", + "build\\generated\\alp-yocto.conf", + "build\\generated\\carrier-netlist.json", + "build\\generated\\alp-west-libs.yml", + "build\\generated\\alp_hw_info_build.h", + "build\\generated\\os-topology.json" + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": "board.yaml", + "root": "." + }, + "sdk": { + "root": "", + "sourceTier": "sdkRootFlag" + } + } + ], + "tests/parity/test_oracle_parity.py::test_harness_reports_a_planted_envelope_difference#0": [ + 2, + { + "__raw__": "" + } + ], + "tests/parity/test_oracle_parity.py::test_harness_reports_a_planted_exit_code_difference#0": [ + 0, + { + "__raw__": "tan 0.4.1" + } + ], + "tests/parity/test_oracle_parity.py::test_harness_reports_a_planted_version_shape_difference[malformed]#0": [ + 0, + { + "__raw__": "tan 0.4.1" + } + ], + "tests/parity/test_oracle_parity.py::test_harness_reports_a_planted_version_shape_difference[trailing-line]#0": [ + 0, + { + "__raw__": "tan 0.4.1" + } + ], + "tests/parity/test_oracle_parity.py::test_harness_reports_a_planted_version_shape_difference[trailing-words]#0": [ + 0, + { + "__raw__": "tan 0.4.1" + } + ], + "tests/parity/test_oracle_parity.py::test_init_sdk_root_flag_pin_is_a_known_divergence_from_the_oracle#0": [ + 0, + { + "command": "init", + "data": { + "destination": ".", + "fileChanges": [ + { + "kind": "new", + "relativePath": "board.yaml" + }, + { + "kind": "new", + "relativePath": "README.md" + }, + { + "kind": "new", + "relativePath": "prj.conf" + }, + { + "kind": "new", + "relativePath": "CMakeLists.txt" + }, + { + "kind": "new", + "relativePath": "src/CMakeLists.txt" + }, + { + "kind": "new", + "relativePath": "include/app/app.h" + }, + { + "kind": "new", + "relativePath": "src/main.c" + }, + { + "kind": "new", + "relativePath": "src/features/app_bootstrap.c" + } + ], + "preview": false, + "schemaVersion": "1", + "sdkPinned": "../rust-sdk", + "templateId": "minimal-app", + "unchanged": [], + "written": [ + "board.yaml", + "README.md", + "prj.conf", + "CMakeLists.txt", + "src/CMakeLists.txt", + "include/app/app.h", + "src/main.c", + "src/features/app_bootstrap.c" + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "." + }, + "sdk": { + "root": "../rust-sdk", + "sourceTier": "sdkRootFlag" + } + }, + "{\n \"sdkPath\": \"../rust-sdk\",\n \"updatedAt\": \"1970-01-01T00:00:00.000Z\"\n}\n" + ], + "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[audio_i2s-tone.build-plan]#0": [ + 0, + { + "command": "build", + "data": { + "boardYaml": "examples/audio/i2s-tone/board.yaml", + "buildRoot": "build", + "executionPolicy": { + "missingTool": "skip", + "nullCommand": "skip", + "unknownBackend": "fail" + }, + "generatedBy": "scripts/alp_orchestrate.py", + "schemaVersion": 1, + "sdkCommit": "97ad481b", + "sdkVersion": "0.11.1", + "sharedArtefacts": [ + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-AEN801\"\n\n/* No ipc[] entries declared in board.yaml; nothing to emit. */\n\n#endif /* ALP_SYSTEM_IPC_H */\n", + "path": "build/generated/alp/system_ipc.h" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n /* No ipc[] carve-outs declared. */\n };\n};\n", + "path": "build/generated/dts-reservations.dtsi" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", + "path": "build/generated/dts-partitions.dtsi" + } + ], + "sku": "E1M-AEN801", + "slices": [ + { + "appDir": null, + "artifacts": { + "bin": null, + "compileCommands": null, + "elf": null, + "map": null, + "sizeReport": null, + "symbols": null + }, + "backend": "yocto", + "buildDir": "build/a32_cluster-yocto", + "command": { + "args": [ + "alp-image-edge" + ], + "cwd": "build/a32_cluster-yocto", + "tool": "bitbake" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- append to local.conf.\n# Per-core slice `a32_cluster` (image: custom)\n# Console: Linux console (kernel `console=` cmdline -> SoM debug UART; userspace -> stdout/tty).\nMACHINE = \"e1m-aen801-a32\"\n", + "path": "build/a32_cluster-yocto/local.conf" + } + ], + "coreId": "a32_cluster", + "debug": { + "console": "linux", + "probe": null + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": null, + "id": "poky-glibc", + "sysroot": null, + "targetTriple": null + } + }, + { + "appDir": "/firmware/alp-stock-shim", + "artifacts": { + "bin": "build/m55_he-zephyr/zephyr/zephyr.bin", + "compileCommands": "build/m55_he-zephyr/compile_commands.json", + "elf": "build/m55_he-zephyr/zephyr/zephyr.elf", + "map": "build/m55_he-zephyr/zephyr/zephyr.map", + "sizeReport": "build/m55_he-zephyr/zephyr/zephyr.stat", + "symbols": "build/m55_he-zephyr/zephyr/zephyr.symbols" + }, + "backend": "zephyr", + "buildDir": "build/m55_he-zephyr", + "command": { + "args": [ + "build", + "-b", + "alp_e1m_aen801_m55_he/ae822fa0e5597ls0/rtss_he", + "/firmware/alp-stock-shim", + "--", + "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" + ], + "cwd": "build/m55_he-zephyr", + "tool": "west" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_he` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_he` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_SPI=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n", + "path": "build/m55_he-zephyr/alp.conf" + } + ], + "coreId": "m55_he", + "debug": { + "console": "uart", + "probe": "openocd" + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": "arm-zephyr-eabi-gcc", + "id": "arm-zephyr-eabi", + "sysroot": null, + "targetTriple": "arm-zephyr-eabi" + } + }, + { + "appDir": "/examples/audio/i2s-tone/src", + "artifacts": { + "bin": "build/m55_hp-zephyr/zephyr/zephyr.bin", + "compileCommands": "build/m55_hp-zephyr/compile_commands.json", + "elf": "build/m55_hp-zephyr/zephyr/zephyr.elf", + "map": "build/m55_hp-zephyr/zephyr/zephyr.map", + "sizeReport": "build/m55_hp-zephyr/zephyr/zephyr.stat", + "symbols": "build/m55_hp-zephyr/zephyr/zephyr.symbols" + }, + "backend": "zephyr", + "buildDir": "build/m55_hp-zephyr", + "command": { + "args": [ + "build", + "-b", + "alp_e1m_aen801_m55_hp/ae822fa0e5597ls0/rtss_hp", + "/examples/audio/i2s-tone", + "--", + "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" + ], + "cwd": "build/m55_hp-zephyr", + "tool": "west" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_hp` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_hp` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_I2S=y\nCONFIG_SPI=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n", + "path": "build/m55_hp-zephyr/alp.conf" + } + ], + "coreId": "m55_hp", + "debug": { + "console": "uart", + "probe": "openocd" + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": "arm-zephyr-eabi-gcc", + "id": "arm-zephyr-eabi", + "sysroot": null, + "targetTriple": "arm-zephyr-eabi" + } + } + ], + "warnings": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[connectivity_iot-fleet-ota.build-plan]#0": [ + 0, + { + "command": "build", + "data": { + "boardYaml": "examples/connectivity/iot-fleet-ota/board.yaml", + "buildRoot": "build", + "executionPolicy": { + "missingTool": "skip", + "nullCommand": "skip", + "unknownBackend": "fail" + }, + "generatedBy": "scripts/alp_orchestrate.py", + "schemaVersion": 1, + "sdkCommit": "97ad481b", + "sdkVersion": "0.11.1", + "sharedArtefacts": [ + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-AEN801\"\n\n/* No ipc[] entries declared in board.yaml; nothing to emit. */\n\n#endif /* ALP_SYSTEM_IPC_H */\n", + "path": "build/generated/alp/system_ipc.h" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n /* No ipc[] carve-outs declared. */\n };\n};\n", + "path": "build/generated/dts-reservations.dtsi" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", + "path": "build/generated/dts-partitions.dtsi" + }, + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py from board.yaml `boot:`.\n# Drives the sysbuild MCUboot child image. Customers who\n# omit `boot:` get the SDK's stock per-family defaults.\n\nSB_CONFIG_BOOTLOADER_MCUBOOT=y\nSB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y\nSB_CONFIG_BOOT_SIGNATURE_KEY_FILE=\"keys/prod_ecdsa_p256.pub.pem\"\nSB_CONFIG_MCUBOOT_MODE_SWAP_SCRATCH=y\n", + "path": "build/alp_sysbuild.conf" + } + ], + "sku": "E1M-AEN801", + "slices": [ + { + "appDir": "/firmware/alp-stock-shim", + "artifacts": { + "bin": "build/m55_he-zephyr/zephyr/zephyr.bin", + "compileCommands": "build/m55_he-zephyr/compile_commands.json", + "elf": "build/m55_he-zephyr/zephyr/zephyr.elf", + "map": "build/m55_he-zephyr/zephyr/zephyr.map", + "sizeReport": "build/m55_he-zephyr/zephyr/zephyr.stat", + "symbols": "build/m55_he-zephyr/zephyr/zephyr.symbols" + }, + "backend": "zephyr", + "buildDir": "build/m55_he-zephyr", + "command": { + "args": [ + "build", + "-b", + "alp_e1m_aen801_m55_he/ae822fa0e5597ls0/rtss_he", + "/firmware/alp-stock-shim", + "--sysbuild", + "--", + "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14", + "-DSB_CONF_FILE=/zephyr/sysbuild/aen/sysbuild.conf;/examples/connectivity/iot-fleet-ota/build/alp_sysbuild.conf" + ], + "cwd": "build/m55_he-zephyr", + "tool": "west" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_he` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_he` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_SPI=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n# OTA Zephyr client (board.yaml `ota.provider: mender`)\n# Mender-MCU-client wiring is pending the v0.7 OTA module\n# (mender-mcu-client west group activation).\n# CONFIG_MENDER_MCU_CLIENT=y\n# CONFIG_MENDER_SERVER_URL=\"https://hosted.mender.io\"\n# CONFIG_MENDER_TENANT_TOKEN=\"${MENDER_TENANT_TOKEN}\"\n# CONFIG_MENDER_ARTIFACT_NAME=\"iot-fleet-ota\"\n# CONFIG_MENDER_UPDATE_POLL_INTERVAL=1800\n\n", + "path": "build/m55_he-zephyr/alp.conf" + } + ], + "coreId": "m55_he", + "debug": { + "console": "uart", + "probe": "openocd" + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": "arm-zephyr-eabi-gcc", + "id": "arm-zephyr-eabi", + "sysroot": null, + "targetTriple": "arm-zephyr-eabi" + } + }, + { + "appDir": "/examples/connectivity/iot-fleet-ota/src", + "artifacts": { + "bin": "build/m55_hp-zephyr/zephyr/zephyr.bin", + "compileCommands": "build/m55_hp-zephyr/compile_commands.json", + "elf": "build/m55_hp-zephyr/zephyr/zephyr.elf", + "map": "build/m55_hp-zephyr/zephyr/zephyr.map", + "sizeReport": "build/m55_hp-zephyr/zephyr/zephyr.stat", + "symbols": "build/m55_hp-zephyr/zephyr/zephyr.symbols" + }, + "backend": "zephyr", + "buildDir": "build/m55_hp-zephyr", + "command": { + "args": [ + "build", + "-b", + "alp_e1m_aen801_m55_hp/ae822fa0e5597ls0/rtss_hp", + "/examples/connectivity/iot-fleet-ota", + "--sysbuild", + "--", + "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14", + "-DSB_CONF_FILE=/zephyr/sysbuild/aen/sysbuild.conf;/examples/connectivity/iot-fleet-ota/build/alp_sysbuild.conf" + ], + "cwd": "build/m55_hp-zephyr", + "tool": "west" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_hp` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_hp` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_SPI=y\n\n# IoT features declared on core `m55_hp` (board.yaml `cores.*.iot`)\n# Wireless provider resolved from `E1M-AEN801` on_module.wifi_ble: cc3501e.\n# Wi-Fi: AEN CC3501E bridge backend, not Zephyr wifi_mgmt.\nCONFIG_ALP_SDK_WIFI_CC3501E=y\n# TLS: credential store + TLS-capable protocol clients.\nCONFIG_TLS_CREDENTIALS=y\n\n# Libraries declared on core `m55_hp`\nCONFIG_MBEDTLS=y\nCONFIG_MBEDTLS_BUILTIN=y\nCONFIG_ALP_MBEDTLS_PURE_C=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n# OTA Zephyr client (board.yaml `ota.provider: mender`)\n# Mender-MCU-client wiring is pending the v0.7 OTA module\n# (mender-mcu-client west group activation).\n# CONFIG_MENDER_MCU_CLIENT=y\n# CONFIG_MENDER_SERVER_URL=\"https://hosted.mender.io\"\n# CONFIG_MENDER_TENANT_TOKEN=\"${MENDER_TENANT_TOKEN}\"\n# CONFIG_MENDER_ARTIFACT_NAME=\"iot-fleet-ota\"\n# CONFIG_MENDER_UPDATE_POLL_INTERVAL=1800\n\n", + "path": "build/m55_hp-zephyr/alp.conf" + } + ], + "coreId": "m55_hp", + "debug": { + "console": "uart", + "probe": "openocd" + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": "arm-zephyr-eabi-gcc", + "id": "arm-zephyr-eabi", + "sysroot": null, + "targetTriple": "arm-zephyr-eabi" + } + } + ], + "warnings": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[multicore_heterogeneous-offload.build-plan]#0": [ + 0, + { + "command": "build", + "data": { + "boardYaml": "examples/multicore/heterogeneous-offload/board.yaml", + "buildRoot": "build", + "executionPolicy": { + "missingTool": "skip", + "nullCommand": "skip", + "unknownBackend": "fail" + }, + "generatedBy": "scripts/alp_orchestrate.py", + "schemaVersion": 1, + "sdkCommit": "97ad481b", + "sdkVersion": "0.11.1", + "sharedArtefacts": [ + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-V2N101\"\n\n/* rpmsg channel 'alp_default_rpmsg' -- endpoints a55_cluster, m33_sm */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_NAME \"alp_default_rpmsg\"\n#define ALP_IPC_ALP_DEFAULT_RPMSG_ADDR 0x00010000u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SIZE 0x00080000u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT 0x000004e6u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_DST_EPT 0x000004e7u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_MBOX_CH 0u\n\n#endif /* ALP_SYSTEM_IPC_H */\n", + "path": "build/generated/alp/system_ipc.h" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n alp_default_rpmsg: alp_default_rpmsg@10000 {\n compatible = \"shared-dma-pool\";\n reg = <0x0 0x00010000 0x0 0x00080000>;\n no-map;\n label = \"alp_default_rpmsg\";\n };\n\n };\n};\n", + "path": "build/generated/dts-reservations.dtsi" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", + "path": "build/generated/dts-partitions.dtsi" + } + ], + "sku": "E1M-V2N101", + "slices": [ + { + "appDir": "/examples/multicore/heterogeneous-offload/linux", + "artifacts": { + "bin": null, + "compileCommands": null, + "elf": null, + "map": null, + "sizeReport": null, + "symbols": null + }, + "backend": "yocto", + "buildDir": "build/a55_cluster-yocto", + "command": { + "args": [ + "alp-image-edge" + ], + "cwd": "build/a55_cluster-yocto", + "tool": "bitbake" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- append to local.conf.\n# Per-core slice `a55_cluster` (image: alp-image-edge)\n# Console: Linux console (kernel `console=` cmdline -> SoM debug UART; userspace -> stdout/tty).\nMACHINE = \"e1m-v2n101-a55\"\nIMAGE_INSTALL:append = \" lib-mbedtls lib-nlohmann-json\"\n# bitbake target: alp-image-edge\n", + "path": "build/a55_cluster-yocto/local.conf" + } + ], + "coreId": "a55_cluster", + "debug": { + "console": "linux", + "probe": null + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": null, + "id": "poky-glibc", + "sysroot": null, + "targetTriple": null + } + }, + { + "appDir": "/examples/multicore/heterogeneous-offload/m33_sm", + "artifacts": { + "bin": "build/m33_sm-zephyr/zephyr/zephyr.bin", + "compileCommands": "build/m33_sm-zephyr/compile_commands.json", + "elf": "build/m33_sm-zephyr/zephyr/zephyr.elf", + "map": "build/m33_sm-zephyr/zephyr/zephyr.map", + "sizeReport": "build/m33_sm-zephyr/zephyr/zephyr.stat", + "symbols": "build/m33_sm-zephyr/zephyr/zephyr.symbols" + }, + "backend": "zephyr", + "buildDir": "build/m33_sm-zephyr", + "command": { + "args": [ + "build", + "-b", + "alp_e1m_v2n101_m33_sm/r9a09g056n48gbg/cm33", + "/examples/multicore/heterogeneous-offload/m33_sm", + "--", + "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" + ], + "cwd": "build/m33_sm-zephyr", + "tool": "west" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m33_sm` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (renesas:rzv2n:n44 via E1M-V2N101)\nCONFIG_ALP_SOC_RENESAS_RZV2N_N44=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_X_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-V2N101` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_ACT8760=y\nCONFIG_ALP_SDK_CHIP_CLK_5L35023B=y\nCONFIG_ALP_SDK_CHIP_DA9292=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_GD32G553=y\nCONFIG_ALP_SDK_CHIP_MURATA_LBEE5HY2FY=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RTL8211FDI=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=n\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_LSM6DSO=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1306=n\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m33_sm` (chip drivers + peripherals)\nCONFIG_ADC=y\nCONFIG_GPIO=y\nCONFIG_I2C=y\n\n# Libraries declared on core `m33_sm`\nCONFIG_CMSIS_DSP=y\nCONFIG_CMSIS_DSP_BASICMATH=y\nCONFIG_CMSIS_DSP_COMPLEXMATH=y\nCONFIG_CMSIS_DSP_CONTROLLER=y\nCONFIG_CMSIS_DSP_FASTMATH=y\nCONFIG_CMSIS_DSP_FILTERING=y\nCONFIG_CMSIS_DSP_INTERPOLATION=y\nCONFIG_CMSIS_DSP_MATRIX=y\nCONFIG_CMSIS_DSP_STATISTICS=y\nCONFIG_CMSIS_DSP_SUPPORT=y\nCONFIG_CMSIS_DSP_TRANSFORM=y\nCONFIG_ALP_CMSIS_DSP_SCALAR=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_REF=y\n\n", + "path": "build/m33_sm-zephyr/alp.conf" + } + ], + "coreId": "m33_sm", + "debug": { + "console": null, + "probe": "openocd" + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": "arm-zephyr-eabi-gcc", + "id": "arm-zephyr-eabi", + "sysroot": null, + "targetTriple": "arm-zephyr-eabi" + } + } + ], + "warnings": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[multicore_rpmsg-aen.build-plan]#0": [ + 0, + { + "command": "build", + "data": { + "boardYaml": "examples/multicore/rpmsg-aen/board.yaml", + "buildRoot": "build", + "executionPolicy": { + "missingTool": "skip", + "nullCommand": "skip", + "unknownBackend": "fail" + }, + "generatedBy": "scripts/alp_orchestrate.py", + "schemaVersion": 1, + "sdkCommit": "97ad481b", + "sdkVersion": "0.11.1", + "sharedArtefacts": [ + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-AEN801\"\n\n/* rpmsg channel 'alp_default_rpmsg' -- endpoints a32_cluster, m55_hp */\n/* BLOCKED: memory_map.base is unset for region 'sram0' in SoM E1M-AEN801; this SoM hasn't been HW-mapped yet so IPC carve-outs cannot be allocated. Add a `memory_map:` block to metadata/e1m_modules/E1M-AEN801.yaml (or per-region `base`) or remove the matching ipc entry from board.yaml. */\n/* IPC channel 'alp_default_rpmsg' is blocked; fix the SoM metadata before depending on this channel at runtime. */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_NAME \"alp_default_rpmsg\"\n#define ALP_IPC_ALP_DEFAULT_RPMSG_ADDR 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SIZE 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_DST_EPT 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_MBOX_CH 0u /* stub: blocked */\n\n#endif /* ALP_SYSTEM_IPC_H */\n", + "path": "build/generated/alp/system_ipc.h" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n /* BLOCKED: alp_default_rpmsg -- memory_map.base is unset for region 'sram0' in SoM E1M-AEN801; this SoM hasn't been HW-mapped yet so IPC carve-outs cannot be allocated. Add a `memory_map:` block to metadata/e1m_modules/E1M-AEN801.yaml (or per-region `base`) or remove the matching ipc entry from board.yaml. */\n\n };\n};\n", + "path": "build/generated/dts-reservations.dtsi" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", + "path": "build/generated/dts-partitions.dtsi" + } + ], + "sku": "E1M-AEN801", + "slices": [ + { + "appDir": "/examples/multicore/rpmsg-aen/linux", + "artifacts": { + "bin": null, + "compileCommands": null, + "elf": null, + "map": null, + "sizeReport": null, + "symbols": null + }, + "backend": "yocto", + "buildDir": "build/a32_cluster-yocto", + "command": { + "args": [ + "alp-image-edge" + ], + "cwd": "build/a32_cluster-yocto", + "tool": "bitbake" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- append to local.conf.\n# Per-core slice `a32_cluster` (image: alp-image-edge)\n# Console: Linux console (kernel `console=` cmdline -> SoM debug UART; userspace -> stdout/tty).\nMACHINE = \"e1m-aen801-a32\"\nIMAGE_INSTALL:append = \" lib-mbedtls lib-nlohmann-json\"\n# bitbake target: alp-image-edge\n", + "path": "build/a32_cluster-yocto/local.conf" + } + ], + "coreId": "a32_cluster", + "debug": { + "console": "linux", + "probe": null + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": null, + "id": "poky-glibc", + "sysroot": null, + "targetTriple": null + } + }, + { + "appDir": "/firmware/alp-stock-shim", + "artifacts": { + "bin": "build/m55_he-zephyr/zephyr/zephyr.bin", + "compileCommands": "build/m55_he-zephyr/compile_commands.json", + "elf": "build/m55_he-zephyr/zephyr/zephyr.elf", + "map": "build/m55_he-zephyr/zephyr/zephyr.map", + "sizeReport": "build/m55_he-zephyr/zephyr/zephyr.stat", + "symbols": "build/m55_he-zephyr/zephyr/zephyr.symbols" + }, + "backend": "zephyr", + "buildDir": "build/m55_he-zephyr", + "command": { + "args": [ + "build", + "-b", + "alp_e1m_aen801_m55_he/ae822fa0e5597ls0/rtss_he", + "/firmware/alp-stock-shim", + "--", + "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" + ], + "cwd": "build/m55_he-zephyr", + "tool": "west" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_he` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_he` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_SPI=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n", + "path": "build/m55_he-zephyr/alp.conf" + } + ], + "coreId": "m55_he", + "debug": { + "console": "uart", + "probe": "openocd" + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": "arm-zephyr-eabi-gcc", + "id": "arm-zephyr-eabi", + "sysroot": null, + "targetTriple": "arm-zephyr-eabi" + } + }, + { + "appDir": "/examples/multicore/rpmsg-aen/m55_hp", + "artifacts": { + "bin": "build/m55_hp-zephyr/zephyr/zephyr.bin", + "compileCommands": "build/m55_hp-zephyr/compile_commands.json", + "elf": "build/m55_hp-zephyr/zephyr/zephyr.elf", + "map": "build/m55_hp-zephyr/zephyr/zephyr.map", + "sizeReport": "build/m55_hp-zephyr/zephyr/zephyr.stat", + "symbols": "build/m55_hp-zephyr/zephyr/zephyr.symbols" + }, + "backend": "zephyr", + "buildDir": "build/m55_hp-zephyr", + "command": { + "args": [ + "build", + "-b", + "alp_e1m_aen801_m55_hp/ae822fa0e5597ls0/rtss_hp", + "/examples/multicore/rpmsg-aen/m55_hp", + "--", + "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" + ], + "cwd": "build/m55_hp-zephyr", + "tool": "west" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m55_hp` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (alif:ensemble:e8 via E1M-AEN801)\nCONFIG_ALP_SOC_ALIF_ENSEMBLE_E8=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-AEN801` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_CC3501E=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m55_hp` (chip drivers + peripherals)\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_SPI=y\n\n# Libraries declared on core `m55_hp`\nCONFIG_CMSIS_DSP=y\nCONFIG_CMSIS_DSP_BASICMATH=y\nCONFIG_CMSIS_DSP_COMPLEXMATH=y\nCONFIG_CMSIS_DSP_CONTROLLER=y\nCONFIG_CMSIS_DSP_FASTMATH=y\nCONFIG_CMSIS_DSP_FILTERING=y\nCONFIG_CMSIS_DSP_INTERPOLATION=y\nCONFIG_CMSIS_DSP_MATRIX=y\nCONFIG_CMSIS_DSP_STATISTICS=y\nCONFIG_CMSIS_DSP_SUPPORT=y\nCONFIG_CMSIS_DSP_TRANSFORM=y\nCONFIG_ALP_CMSIS_DSP_SCALAR=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_HELIUM=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_AEN=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U55=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U85=y\n\n", + "path": "build/m55_hp-zephyr/alp.conf" + } + ], + "coreId": "m55_hp", + "debug": { + "console": "uart", + "probe": "openocd" + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": "arm-zephyr-eabi-gcc", + "id": "arm-zephyr-eabi", + "sysroot": null, + "targetTriple": "arm-zephyr-eabi" + } + } + ], + "warnings": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[multicore_rpmsg-imx93.build-plan]#0": [ + 0, + { + "command": "build", + "data": { + "boardYaml": "examples/multicore/rpmsg-imx93/board.yaml", + "buildRoot": "build", + "executionPolicy": { + "missingTool": "skip", + "nullCommand": "skip", + "unknownBackend": "fail" + }, + "generatedBy": "scripts/alp_orchestrate.py", + "schemaVersion": 1, + "sdkCommit": "97ad481b", + "sdkVersion": "0.11.1", + "sharedArtefacts": [ + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-NX9101\"\n\n/* rpmsg channel 'alp_default_rpmsg' -- endpoints a55_cluster, m33 */\n/* BLOCKED: SoM E1M-NX9101 mailbox controller is TBD; carve-out resolution requires authoritative mailbox metadata. Fill `mailbox.controller:` in metadata/e1m_modules/E1M-NX9101.yaml with the vendor mailbox node name (e.g. `renesas_mhu`, `nxp_mu`, `alif_mhuv2`) or remove the rpmsg entries from board.yaml. */\n/* IPC channel 'alp_default_rpmsg' is blocked; fix the SoM metadata before depending on this channel at runtime. */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_NAME \"alp_default_rpmsg\"\n#define ALP_IPC_ALP_DEFAULT_RPMSG_ADDR 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SIZE 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_DST_EPT 0x0u /* stub: blocked */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_MBOX_CH 0u /* stub: blocked */\n\n#endif /* ALP_SYSTEM_IPC_H */\n", + "path": "build/generated/alp/system_ipc.h" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n /* BLOCKED: alp_default_rpmsg -- SoM E1M-NX9101 mailbox controller is TBD; carve-out resolution requires authoritative mailbox metadata. Fill `mailbox.controller:` in metadata/e1m_modules/E1M-NX9101.yaml with the vendor mailbox node name (e.g. `renesas_mhu`, `nxp_mu`, `alif_mhuv2`) or remove the rpmsg entries from board.yaml. */\n\n };\n};\n", + "path": "build/generated/dts-reservations.dtsi" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", + "path": "build/generated/dts-partitions.dtsi" + } + ], + "sku": "E1M-NX9101", + "slices": [ + { + "appDir": "/examples/multicore/rpmsg-imx93/linux", + "artifacts": { + "bin": null, + "compileCommands": null, + "elf": null, + "map": null, + "sizeReport": null, + "symbols": null + }, + "backend": "yocto", + "buildDir": "build/a55_cluster-yocto", + "command": { + "args": [ + "alp-image-edge" + ], + "cwd": "build/a55_cluster-yocto", + "tool": "bitbake" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- append to local.conf.\n# Per-core slice `a55_cluster` (image: alp-image-edge)\n# Console: Linux console (kernel `console=` cmdline -> SoM debug UART; userspace -> stdout/tty).\nMACHINE = \"e1m-nx9101-a55\"\nIMAGE_INSTALL:append = \" lib-mbedtls lib-nlohmann-json\"\n# bitbake target: alp-image-edge\n", + "path": "build/a55_cluster-yocto/local.conf" + } + ], + "coreId": "a55_cluster", + "debug": { + "console": "linux", + "probe": null + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": null, + "id": "poky-glibc", + "sysroot": null, + "targetTriple": null + } + }, + { + "appDir": "/examples/multicore/rpmsg-imx93/m33", + "artifacts": { + "bin": "build/m33-zephyr/zephyr/zephyr.bin", + "compileCommands": "build/m33-zephyr/compile_commands.json", + "elf": "build/m33-zephyr/zephyr/zephyr.elf", + "map": "build/m33-zephyr/zephyr/zephyr.map", + "sizeReport": "build/m33-zephyr/zephyr/zephyr.stat", + "symbols": "build/m33-zephyr/zephyr/zephyr.symbols" + }, + "backend": "zephyr", + "buildDir": "build/m33-zephyr", + "command": null, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m33` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# Console: Alp UART console (auto-selected for os: zephyr).\n# printf/LOG land on the module's console UART (the board layer\n# owns the `zephyr,console` DT chosen + pinmux -- e.g. E1M edge\n# UART0). Override in board.yaml with `diagnostics.console: ram`\n# (SWD bench flow) or `none` (inherit the board default).\nCONFIG_SERIAL=y\nCONFIG_CONSOLE=y\nCONFIG_UART_CONSOLE=y\nCONFIG_UART_INTERRUPT_DRIVEN=y\n\n# SoM silicon (nxp:imx9:imx93 via E1M-NX9101)\nCONFIG_ALP_SOC_NXP_IMX9_IMX93=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-NX9101` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_PCA9451A=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=y\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m33` (chip drivers + peripherals)\nCONFIG_ADC=y\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_PWM=y\n\n# Libraries declared on core `m33`\nCONFIG_CMSIS_DSP=y\nCONFIG_CMSIS_DSP_BASICMATH=y\nCONFIG_CMSIS_DSP_COMPLEXMATH=y\nCONFIG_CMSIS_DSP_CONTROLLER=y\nCONFIG_CMSIS_DSP_FASTMATH=y\nCONFIG_CMSIS_DSP_FILTERING=y\nCONFIG_CMSIS_DSP_INTERPOLATION=y\nCONFIG_CMSIS_DSP_MATRIX=y\nCONFIG_CMSIS_DSP_STATISTICS=y\nCONFIG_CMSIS_DSP_SUPPORT=y\nCONFIG_CMSIS_DSP_TRANSFORM=y\nCONFIG_ALP_CMSIS_DSP_SCALAR=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_REF=y\nCONFIG_ALP_SDK_INFERENCE_BACKEND_ETHOS_U_N93=y\nCONFIG_ALP_SDK_INFERENCE_ETHOS_U_VARIANT_U65=y\n\n", + "path": "build/m33-zephyr/alp.conf" + } + ], + "coreId": "m33", + "debug": { + "console": "uart", + "probe": "openocd" + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": "arm-zephyr-eabi-gcc", + "id": "arm-zephyr-eabi", + "sysroot": null, + "targetTriple": "arm-zephyr-eabi" + } + } + ], + "warnings": [ + { + "code": "board-tree-missing", + "coreId": "m33", + "message": "SoM 'E1M-NX9101' core 'm33' wants Zephyr board 'alp_e1m_nx9101_m33', which has no tree under zephyr/boards/alp/ -- board bring-up for this target has not happened yet." + } + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_plan_from_shows_the_plan_and_writes_nothing[multicore_rpmsg-v2n.build-plan]#0": [ + 0, + { + "command": "build", + "data": { + "boardYaml": "examples/multicore/rpmsg-v2n/board.yaml", + "buildRoot": "build", + "executionPolicy": { + "missingTool": "skip", + "nullCommand": "skip", + "unknownBackend": "fail" + }, + "generatedBy": "scripts/alp_orchestrate.py", + "schemaVersion": 1, + "sdkCommit": "97ad481b", + "sdkVersion": "0.11.1", + "sharedArtefacts": [ + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map / mailbox blocks.\n */\n\n#ifndef ALP_SYSTEM_IPC_H\n#define ALP_SYSTEM_IPC_H\n\n#define ALP_IPC_SKU \"E1M-V2N101\"\n\n/* rpmsg channel 'alp_default_rpmsg' -- endpoints a55_cluster, m33_sm */\n#define ALP_IPC_ALP_DEFAULT_RPMSG_NAME \"alp_default_rpmsg\"\n#define ALP_IPC_ALP_DEFAULT_RPMSG_ADDR 0x00010000u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SIZE 0x00080000u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT 0x000004e6u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_DST_EPT 0x000004e7u\n#define ALP_IPC_ALP_DEFAULT_RPMSG_MBOX_CH 0u\n\n#endif /* ALP_SYSTEM_IPC_H */\n", + "path": "build/generated/alp/system_ipc.h" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `ipc:` or the SoM's\n * memory_map block. #include this file from your kernel /\n * Zephyr DT.\n */\n\n/ {\n reserved-memory {\n #address-cells = <2>;\n #size-cells = <2>;\n\n alp_default_rpmsg: alp_default_rpmsg@10000 {\n compatible = \"shared-dma-pool\";\n reg = <0x0 0x00010000 0x0 0x00080000>;\n no-map;\n label = \"alp_default_rpmsg\";\n };\n\n };\n};\n", + "path": "build/generated/dts-reservations.dtsi" + }, + { + "contents": "/*\n * Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n * Regenerate after changes to board.yaml `storage:` or the SoM's\n * memory_map / on_module.ospi_memories blocks. #include this file\n * from your Zephyr DT.\n */\n\n/* No `storage:` entries declared in board.yaml; nothing to emit. */\n", + "path": "build/generated/dts-partitions.dtsi" + } + ], + "sku": "E1M-V2N101", + "slices": [ + { + "appDir": "/examples/multicore/rpmsg-v2n/linux", + "artifacts": { + "bin": null, + "compileCommands": null, + "elf": null, + "map": null, + "sizeReport": null, + "symbols": null + }, + "backend": "yocto", + "buildDir": "build/a55_cluster-yocto", + "command": { + "args": [ + "alp-image-edge" + ], + "cwd": "build/a55_cluster-yocto", + "tool": "bitbake" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- append to local.conf.\n# Per-core slice `a55_cluster` (image: alp-image-edge)\n# Console: Linux console (kernel `console=` cmdline -> SoM debug UART; userspace -> stdout/tty).\nMACHINE = \"e1m-v2n101-a55\"\n# Peripherals declared for this Yocto slice are BSP/kernel-owned (emmc, ethernet, usb); no Zephyr Kconfig or local.conf package knob is emitted here.\n\n# IoT features declared on core `a55_cluster` (board.yaml `cores.*.iot`)\n# Wireless provider resolved from `E1M-V2N101` on_module.wifi_ble: murata_lbee5hy2fy.\n# Wi-Fi: Linux owns this provider's SDIO/firmware path; BSP/machine recipes supply kernel/firmware packages.\nIMAGE_INSTALL:append = \" wpa-supplicant iw wireless-regdb ca-certificates\"\nPACKAGECONFIG:append:pn-alp-sdk = \" mqtt security\"\nIMAGE_INSTALL:append = \" lib-mbedtls lib-nlohmann-json\"\n# bitbake target: alp-image-edge\n", + "path": "build/a55_cluster-yocto/local.conf" + } + ], + "coreId": "a55_cluster", + "debug": { + "console": "linux", + "probe": null + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": null, + "id": "poky-glibc", + "sysroot": null, + "targetTriple": null + } + }, + { + "appDir": "/examples/multicore/rpmsg-v2n/m33_sm", + "artifacts": { + "bin": "build/m33_sm-zephyr/zephyr/zephyr.bin", + "compileCommands": "build/m33_sm-zephyr/compile_commands.json", + "elf": "build/m33_sm-zephyr/zephyr/zephyr.elf", + "map": "build/m33_sm-zephyr/zephyr/zephyr.map", + "sizeReport": "build/m33_sm-zephyr/zephyr/zephyr.stat", + "symbols": "build/m33_sm-zephyr/zephyr/zephyr.symbols" + }, + "backend": "zephyr", + "buildDir": "build/m33_sm-zephyr", + "command": { + "args": [ + "build", + "-b", + "alp_e1m_v2n101_m33_sm/r9a09g056n48gbg/cm33", + "/examples/multicore/rpmsg-v2n/m33_sm", + "--", + "-DPython3_EXECUTABLE=/opt/homebrew/opt/python@3.14/bin/python3.14" + ], + "cwd": "build/m33_sm-zephyr", + "tool": "west" + }, + "configArtefacts": [ + { + "contents": "# Auto-generated by scripts/alp_orchestrate.py -- do not edit.\n# Per-core Kconfig fragment for slice `m33_sm` (zephyr).\n\nCONFIG_ALP_SDK=y\nCONFIG_LOG=y\nCONFIG_PRINTK=y\nCONFIG_THREAD_LOCAL_STORAGE=y\nCONFIG_LOG_DEFAULT_LEVEL=3\n\n# On-module HW manifest EEPROM -> portable bus 0 (alp-i2c0);\n# enables alp_hw_info_read() + the console banner manifest line.\nCONFIG_ALP_SDK_HW_INFO_EEPROM_I2C_BUS_ID=0\n\n# SoM silicon (renesas:rzv2n:n44 via E1M-V2N101)\nCONFIG_ALP_SOC_RENESAS_RZV2N_N44=y\n\n# Cross-EVK board facade selector (); CONFIG_COMPILER_OPT is single-value (do not also set in prj.conf).\nCONFIG_COMPILER_OPT=\"-DALP_BOARD_E1M_X_EVK\"\n\n# SoM-intrinsic chip drivers (from `E1M-V2N101` on_module + helper_firmware)\nCONFIG_ALP_SDK_CHIP_ACT8760=y\nCONFIG_ALP_SDK_CHIP_CLK_5L35023B=y\nCONFIG_ALP_SDK_CHIP_DA9292=y\nCONFIG_ALP_SDK_CHIP_EEPROM_24C128=y\nCONFIG_ALP_SDK_CHIP_GD32G553=y\nCONFIG_ALP_SDK_CHIP_MURATA_LBEE5HY2FY=y\nCONFIG_ALP_SDK_CHIP_OPTIGA_TRUST_M=y\nCONFIG_ALP_SDK_CHIP_RTL8211FDI=y\nCONFIG_ALP_SDK_CHIP_RV3028C7=y\nCONFIG_ALP_SDK_CHIP_TMP112=y\n\n# Board-populated chip drivers (from the resolved board definition)\nCONFIG_ALP_SDK_CHIP_BME280=n\nCONFIG_ALP_SDK_CHIP_BMI323=y\nCONFIG_ALP_SDK_CHIP_BMP581=y\nCONFIG_ALP_SDK_BLOCK_BUTTON_LED=y\nCONFIG_ALP_SDK_CHIP_CAM_MUX_PI3WVR626=n\nCONFIG_ALP_SDK_CHIP_ICM42670=y\nCONFIG_ALP_SDK_CHIP_INA236=y\nCONFIG_ALP_SDK_CHIP_LIS2DW12=n\nCONFIG_ALP_SDK_CHIP_LSM6DSO=n\nCONFIG_ALP_SDK_CHIP_OV5640=n\nCONFIG_ALP_SDK_BLOCK_PDM_MIC=y\nCONFIG_ALP_SDK_CHIP_SSD1306=n\nCONFIG_ALP_SDK_CHIP_SSD1331=n\nCONFIG_ALP_SDK_CHIP_TAS2563=y\nCONFIG_ALP_SDK_CHIP_TCAL9538=y\n\n# Zephyr subsystems required on core `m33_sm` (chip drivers + peripherals)\nCONFIG_ADC=y\nCONFIG_GPIO=y\nCONFIG_I2C=y\nCONFIG_PWM=y\n\n# Libraries declared on core `m33_sm`\nCONFIG_CMSIS_DSP=y\nCONFIG_CMSIS_DSP_BASICMATH=y\nCONFIG_CMSIS_DSP_COMPLEXMATH=y\nCONFIG_CMSIS_DSP_CONTROLLER=y\nCONFIG_CMSIS_DSP_FASTMATH=y\nCONFIG_CMSIS_DSP_FILTERING=y\nCONFIG_CMSIS_DSP_INTERPOLATION=y\nCONFIG_CMSIS_DSP_MATRIX=y\nCONFIG_CMSIS_DSP_STATISTICS=y\nCONFIG_CMSIS_DSP_SUPPORT=y\nCONFIG_CMSIS_DSP_TRANSFORM=y\nCONFIG_ALP_CMSIS_DSP_SCALAR=y\n\n# Inference dispatchers (from SoM capabilities -- customer does not pick)\nCONFIG_ALP_SDK_INFERENCE_BACKEND_TFLM=y\nCONFIG_ALP_SDK_INFERENCE_TFLM_KERNEL_REF=y\n\n", + "path": "build/m33_sm-zephyr/alp.conf" + } + ], + "coreId": "m33_sm", + "debug": { + "console": null, + "probe": "openocd" + }, + "env": { + "ALP_SDK_ROOT": "" + }, + "envAppendPath": { + "EXTRA_ZEPHYR_MODULES": [ + "" + ], + "PYTHONPATH": [ + "/scripts" + ] + }, + "toolchain": { + "compiler": "arm-zephyr-eabi-gcc", + "id": "arm-zephyr-eabi", + "sysroot": null, + "targetTriple": "arm-zephyr-eabi" + } + } + ], + "warnings": [] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_plan_from_with_materialise_writes_every_artefact#0": [ + 0, + { + "command": "build", + "data": { + "baseDir": "", + "schemaVersion": "1", + "written": [ + "build/generated/alp/system_ipc.h", + "build/generated/dts-reservations.dtsi", + "build/generated/dts-partitions.dtsi", + "build/a55_cluster-yocto/local.conf", + "build/m33_sm-zephyr/alp.conf" + ] + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_python_matches_rust[--format json debug-config --target-kind bogus]#0": [ + 5, + { + "command": "debug-config", + "data": { + "configuration": null, + "generatedAt": "1970-01-01T00:00:00.000Z", + "launchJsonPath": "\\.vscode\\launch.json", + "notes": [], + "preview": false, + "replaced": false, + "schemaVersion": "1", + "server": "none", + "targetKind": "zephyr-mcu" + }, + "exitCode": 5, + "issues": [ + { + "code": "debug-config.internal-failure", + "message": "Unsupported --target-kind 'bogus'. Allowed values: zephyr-mcu, baremetal-mcu, yocto-userspace, native-host.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": null + } + } + ], + "tests/parity/test_oracle_parity.py::test_python_matches_rust[--format json debug-config --target-kind native-host --preview]#0": [ + 0, + { + "command": "debug-config", + "data": { + "configuration": { + "cwd": "${workspaceFolder}", + "name": "Alp: Native Sim Debug", + "program": "${workspaceFolder}/build/native_sim/zephyr/zephyr.exe", + "request": "launch", + "type": "lldb" + }, + "generatedAt": "1970-01-01T00:00:00.000Z", + "launchJsonPath": "\\.vscode\\launch.json", + "notes": [ + "This is a draft launch configuration generated by tan.", + "The long-term target is to resolve these values from the shared debug model." + ], + "preview": true, + "replaced": false, + "schemaVersion": "1", + "server": "none", + "targetKind": "native-host" + }, + "exitCode": 0, + "issues": [], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_python_matches_rust[--version]#0": [ + 0, + { + "__raw__": "tan 0.4.1" + } + ], + "tests/parity/test_oracle_parity.py::test_python_matches_rust[]#0": [ + 2, + { + "__raw__": "" + } + ], + "tests/parity/test_oracle_parity.py::test_python_matches_rust[bogus-command]#0": [ + 2, + { + "__raw__": "" + } + ], + "tests/parity/test_oracle_parity.py::test_python_matches_rust[build --plan --format json]#0": [ + 1, + { + "command": "build", + "data": null, + "exitCode": 1, + "issues": [ + { + "code": "build.plan-unavailable", + "message": "no alp-sdk checkout found \u2014 pass `--sdk-root `, pin one with `tan sdk switch `, set it in settings, or run `tan bootstrap`. The build-plan comes from the SDK's `alp_orchestrate --emit build-plan`.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_python_matches_rust[clean --format json]#0": [ + 1, + { + "command": "clean", + "data": { + "buildRoot": "", + "dryRun": false, + "removed": 0, + "targets": [] + }, + "exitCode": 1, + "issues": [ + { + "code": "clean.sdk-root-not-found", + "message": "Cannot locate alp-sdk root.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_python_matches_rust[presets --format json]#0": [ + 0, + { + "command": "presets", + "data": { + "boardLibraries": [], + "inferenceBackends": [ + "auto", + "cpu", + "ethos_u", + "drpai", + "deepx_dxm1" + ], + "libraries": [ + "etl", + "fmt", + "nlohmann_json", + "doctest", + "lvgl", + "mbedtls", + "cmsis_dsp", + "littlefs" + ], + "logLevels": [ + "error", + "warn", + "info", + "debug", + "trace" + ], + "osChoices": [ + "zephyr", + "yocto", + "baremetal" + ], + "schemaVersion": "1", + "sdkRoot": null, + "skus": [], + "soms": [] + }, + "exitCode": 0, + "issues": [ + { + "code": "presets.sdk-root-unresolved", + "message": "alp-sdk root is unresolved. Returning built-in defaults and empty SDK preset lists.", + "severity": "warning" + } + ], + "ok": true, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_python_matches_rust[validate --format json]#0": [ + 2, + { + "command": "validate", + "data": { + "boardYamlPath": "/board.yaml", + "commandLine": "", + "issueCount": 1, + "outcome": "failed", + "schemaVersion": "1" + }, + "exitCode": 2, + "issues": [ + { + "code": "validate.board-yaml-missing", + "message": "board.yaml path could not be resolved or the file does not exist.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_sdk_switch_unresolvable_version_is_a_known_divergence_from_the_oracle#0": [ + 1, + { + "command": "sdk", + "data": { + "scope": "project", + "sdkPath": "\\.alp\\sdk-cache\\9.9.9-does-not-exist", + "subcommand": "switch", + "version": null + }, + "exitCode": 1, + "issues": [ + { + "code": "sdk.path-not-found", + "message": "SDK path not found: \\.alp\\sdk-cache\\9.9.9-does-not-exist", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": null + } + } + ], + "tests/parity/test_oracle_parity.py::test_validate_board_yaml_missing_guard_matches_the_oracle_at_exit_2#0": [ + 2, + { + "command": "validate", + "data": { + "boardYamlPath": "/board.yaml", + "commandLine": "", + "issueCount": 1, + "outcome": "failed", + "schemaVersion": "1" + }, + "exitCode": 2, + "issues": [ + { + "code": "validate.board-yaml-missing", + "message": "board.yaml path could not be resolved or the file does not exist.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_validate_no_sdk_guard_is_a_known_divergence_from_the_oracle#0": [ + 2, + { + "command": "validate", + "data": { + "boardYamlPath": "/board.yaml", + "commandLine": "", + "issueCount": 1, + "outcome": "failed", + "schemaVersion": "1" + }, + "exitCode": 2, + "issues": [ + { + "code": "validate.sdk-root-unresolved", + "message": "alp-sdk root is unresolved. Use --sdk-root or place project near alp-sdk checkout.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": "/board.yaml", + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_west_forward_matches_rust[lock]#0": [ + 1, + { + "command": "lock", + "data": { + "args": [ + "--core", + "m55_hp", + "-b", + "some_board" + ], + "schemaVersion": "1", + "westCommand": "alp-lock", + "westCwd": "" + }, + "exitCode": 1, + "issues": [ + { + "code": "lock.failed", + "message": "west not found on PATH \u2014 run `tan bootstrap` and ensure west is on PATH.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_west_forward_matches_rust[migrate]#0": [ + 1, + { + "command": "migrate", + "data": { + "args": [ + "--core", + "m55_hp", + "-b", + "some_board" + ], + "schemaVersion": "1", + "westCommand": "alp-migrate", + "westCwd": "" + }, + "exitCode": 1, + "issues": [ + { + "code": "migrate.failed", + "message": "west not found on PATH \u2014 run `tan bootstrap` and ensure west is on PATH.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + } + ], + "tests/parity/test_oracle_parity.py::test_west_forward_matches_rust[quality]#0": [ + 1, + { + "command": "quality", + "data": { + "args": [ + "--core", + "m55_hp", + "-b", + "some_board" + ], + "schemaVersion": "1", + "westCommand": "alp-quality", + "westCwd": "" + }, + "exitCode": 1, + "issues": [ + { + "code": "quality.failed", + "message": "west not found on PATH \u2014 run `tan bootstrap` and ensure west is on PATH.", + "severity": "error" + } + ], + "ok": false, + "project": { + "boardYaml": null, + "root": "" + } + } + ] +} diff --git a/python/tests/parity/oracle_fixtures/test_run_oracle_parity.json b/python/tests/parity/oracle_fixtures/test_run_oracle_parity.json index edf3bb9a..1386171d 100644 --- a/python/tests/parity/oracle_fixtures/test_run_oracle_parity.json +++ b/python/tests/parity/oracle_fixtures/test_run_oracle_parity.json @@ -1,46 +1,6 @@ -{ - "tests/parity/test_run_oracle_parity.py::test_declared_flags_all_exist_in_the_real_run_help#0": "Build the project, then run it: execute the produced native_sim binary for a host target, or flash a hardware target (native)\n\nUsage: tan.exe run [OPTIONS]\n\nOptions:\n --flash\n Program the board after building (hardware targets only). Required opt-in: without it, `run` on a hardware project builds and reports but never flashes. Ignored for a native_sim/host target, which always runs the produced binary and never flashes\n\n --project \n Project root (defaults to current directory)\n\n --board-yaml \n Explicit board.yaml path (overrides project resolution)\n\n --core \n With `--flash`, flash only the slice with this `core_id` (forwarded verbatim to the native flash path's `--core`)\n\n --sdk-root \n alp-sdk checkout root\n\n --target \n Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf)\n\n --all\n Run command against all relevant targets\n\n --format \n Output format\n\n Possible values:\n - text: Human-readable text (default)\n - json: Machine-readable JSON envelope\n \n [default: text]\n\n --verbose\n Emit additional diagnostic detail\n\n --quiet\n Suppress non-essential output\n\n --no-color\n Disable ANSI color in text output\n\n --non-interactive\n Never prompt. A command with a documented default takes it (`tan init` scaffolds `zephyr-app` into `.`); one without fails instead of asking (`tan scaffold` needs `--name`). The same rule applies unasked when stdin or stderr is not a terminal \u2014 piped, redirected, or a CI runner\n\n --ci\n CI mode: implies non-interactive and disables color\n\n -h, --help\n Print help (see a summary with '-h')\n\n -V, --version\n Print version\n", - "tests/parity/test_run_oracle_parity.py::test_run_help_is_not_the_same_flag_set_as_build_or_flash#0": "Build the project, then run it: execute the produced native_sim binary for a host target, or flash a hardware target (native)\n\nUsage: tan.exe run [OPTIONS]\n\nOptions:\n --flash\n Program the board after building (hardware targets only). Required opt-in: without it, `run` on a hardware project builds and reports but never flashes. Ignored for a native_sim/host target, which always runs the produced binary and never flashes\n\n --project \n Project root (defaults to current directory)\n\n --board-yaml \n Explicit board.yaml path (overrides project resolution)\n\n --core \n With `--flash`, flash only the slice with this `core_id` (forwarded verbatim to the native flash path's `--core`)\n\n --sdk-root \n alp-sdk checkout root\n\n --target \n Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf)\n\n --all\n Run command against all relevant targets\n\n --format \n Output format\n\n Possible values:\n - text: Human-readable text (default)\n - json: Machine-readable JSON envelope\n \n [default: text]\n\n --verbose\n Emit additional diagnostic detail\n\n --quiet\n Suppress non-essential output\n\n --no-color\n Disable ANSI color in text output\n\n --non-interactive\n Never prompt. A command with a documented default takes it (`tan init` scaffolds `zephyr-app` into `.`); one without fails instead of asking (`tan scaffold` needs `--name`). The same rule applies unasked when stdin or stderr is not a terminal \u2014 piped, redirected, or a CI runner\n\n --ci\n CI mode: implies non-interactive and disables color\n\n -h, --help\n Print help (see a summary with '-h')\n\n -V, --version\n Print version\n", - "tests/parity/test_run_oracle_parity.py::test_run_help_is_not_the_same_flag_set_as_build_or_flash#1": "Build the project natively: consume the SDK's emitted build plan, materialise its files, then run each per-core slice's command directly\n\nUsage: tan.exe build [OPTIONS]\n\nOptions:\n --plan\n Show the build plan (consumed from the SDK's `--emit build-plan`) and exit without building\n\n --project \n Project root (defaults to current directory)\n\n --board-yaml \n Explicit board.yaml path (overrides project resolution)\n\n --plan-from \n Read the build plan from a JSON file instead of invoking the SDK. Implies `--plan`. Use this to consume `alp_orchestrate.py --emit build-plan` output instead of the live emit (which is the default plan source)\n\n --materialise\n Materialise the plan: write its generated files (shared artefacts + per-slice config) to disk under the build root, instead of just showing the plan. With no `--plan-from`, the plan is fetched live from the SDK\n\n --sdk-root \n alp-sdk checkout root\n\n --native\n Build natively: consume the plan, materialise its files, then run each slice's command (`west` / `bitbake` / `cmake`) sequentially. This is the default; the flag is kept as an explicit opt-in\n\n --target \n Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf)\n\n --all\n Run command against all relevant targets\n\n --manifest\n Show the system manifest \u2014 the post-build IDE/tool contract (`build/system-manifest.yaml`): per-core slices + ipc + helper MCUs. Without `--manifest-from`, asks the SDK for the projection (`alp_orchestrate.py --emit system-manifest`)\n\n --format \n Output format\n\n Possible values:\n - text: Human-readable text (default)\n - json: Machine-readable JSON envelope\n \n [default: text]\n\n --manifest-from \n Read the system manifest from a YAML file instead of invoking the SDK (e.g. the `build/system-manifest.yaml` a build already wrote). Implies `--manifest`\n\n --no-auto-bootstrap\n Never bootstrap implicitly. By default a text-mode build with no Zephyr workspace (or a stale one) runs `tan bootstrap` first, which clones Zephyr + the HALs beside the SDK checkout and takes minutes. Use this to keep `tan build` to building and get the readiness report instead\n\n --verbose\n Emit additional diagnostic detail\n\n --pristine\n Force-wipe every slice's build dir before dispatch, regardless of the recorded SDK-switch stamp (tan-cli#163) \u2014 the manual counterpart to the automatic sdk-switch-pristine wipe, for a stale build dir the stamp heuristic doesn't (or can't yet) catch. Same wipe, same two safety guards (an explicit `-d`/`--build-dir` in the slice's own command, or a plan cwd outside `build/`): this never touches a dir tan can't vouch for, same as the automatic path. A slice the wipe declines \u2014 for either guard, or because the dir was never configured \u2014 says so on the envelope and in text (`build.pristine-skipped`, tan-cli#183), so \"pristine\" never silently means \"incremental\"\n\n --quiet\n Suppress non-essential output\n\n --no-color\n Disable ANSI color in text output\n\n --non-interactive\n Never prompt. A command with a documented default takes it (`tan init` scaffolds `zephyr-app` into `.`); one without fails instead of asking (`tan scaffold` needs `--name`). The same rule applies unasked when stdin or stderr is not a terminal \u2014 piped, redirected, or a CI runner\n\n --ci\n CI mode: implies non-interactive and disables color\n\n -h, --help\n Print help (see a summary with '-h')\n\n -V, --version\n Print version\n", - "tests/parity/test_run_oracle_parity.py::test_run_help_is_not_the_same_flag_set_as_build_or_flash#2": "Flash every slice + helper MCU from `build/system-manifest.yaml` onto the device in `boot_order` (native)\n\nUsage: tan.exe flash [OPTIONS] [APP_PATH]\n\nArguments:\n [APP_PATH]\n Application source directory (default: `.`, the current directory). `build_root` defaults to `/build`. Optional positional (the unified app-path convention: `tan flash` from the app dir needs no argument, matching the retired Python `app_path` default)\n \n [default: .]\n\nOptions:\n --build-root \n Override the build root holding `system-manifest.yaml` (default: `/build`)\n\n --project \n Project root (defaults to current directory)\n\n --board-yaml \n Explicit board.yaml path (overrides project resolution)\n\n --dry-run\n Print the flash command each backend WOULD run and return ok without spawning; also bypasses the required-tool PATH gate\n\n --core \n Flash only the slice with this `core_id` (skips every other slice AND all helpers)\n\n --sdk-root \n alp-sdk checkout root\n\n --helper \n Flash only the helper MCU with this name (skips ALL slices and every other helper)\n\n --target \n Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf)\n\n --all\n Run command against all relevant targets\n\n --skip-missing-tools\n When a backend's required tools are all absent from PATH, warn + skip the entry instead of failing it. No effect under `--dry-run`\n\n --format \n Output format\n\n Possible values:\n - text: Human-readable text (default)\n - json: Machine-readable JSON envelope\n \n [default: text]\n\n --verbose\n Emit additional diagnostic detail\n\n --quiet\n Suppress non-essential output\n\n --no-color\n Disable ANSI color in text output\n\n --non-interactive\n Never prompt. A command with a documented default takes it (`tan init` scaffolds `zephyr-app` into `.`); one without fails instead of asking (`tan scaffold` needs `--name`). The same rule applies unasked when stdin or stderr is not a terminal \u2014 piped, redirected, or a CI runner\n\n --ci\n CI mode: implies non-interactive and disables color\n\n -h, --help\n Print help (see a summary with '-h')\n\n -V, --version\n Print version\n", - "tests/parity/test_run_oracle_parity.py::test_run_matches_the_rust_oracle_on_the_build_failed_path[no-sdk-found]#0": [ - 1, - { - "command": "run", - "data": null, - "exitCode": 1, - "issues": [ - { - "code": "build.plan-unavailable", - "message": "no alp-sdk checkout found \u2014 pass `--sdk-root `, pin one with `tan sdk switch `, set it in settings, or run `tan bootstrap`. The build-plan comes from the SDK's `alp_orchestrate --emit build-plan`.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - } - ], - "tests/parity/test_run_oracle_parity.py::test_run_matches_the_rust_oracle_on_the_build_failed_path[sdk-root-invalid]#0": [ - 1, - { - "command": "run", - "data": null, - "exitCode": 1, - "issues": [ - { - "code": "build.plan-unavailable", - "message": "no alp-sdk checkout found \u2014 pass `--sdk-root `, pin one with `tan sdk switch `, set it in settings, or run `tan bootstrap`. The build-plan comes from the SDK's `alp_orchestrate --emit build-plan`.", - "severity": "error" - } - ], - "ok": false, - "project": { - "boardYaml": null, - "root": "" - } - } - ] -} +{ + "tests/parity/test_run_oracle_parity.py::test_declared_flags_all_exist_in_the_real_run_help#0": "Build the project, then run it: execute the produced native_sim binary for a host target, or flash a hardware target (native)\n\nUsage: tan.exe run [OPTIONS]\n\nOptions:\n --flash\n Program the board after building (hardware targets only). Required opt-in: without it, `run` on a hardware project builds and reports but never flashes. Ignored for a native_sim/host target, which always runs the produced binary and never flashes\n\n --project \n Project root (defaults to current directory)\n\n --board-yaml \n Explicit board.yaml path (overrides project resolution)\n\n --core \n With `--flash`, flash only the slice with this `core_id` (forwarded verbatim to the native flash path's `--core`)\n\n --sdk-root \n alp-sdk checkout root\n\n --target \n Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf)\n\n --all\n Run command against all relevant targets\n\n --format \n Output format\n\n Possible values:\n - text: Human-readable text (default)\n - json: Machine-readable JSON envelope\n \n [default: text]\n\n --verbose\n Emit additional diagnostic detail\n\n --quiet\n Suppress non-essential output\n\n --no-color\n Disable ANSI color in text output\n\n --non-interactive\n Never prompt. A command with a documented default takes it (`tan init` scaffolds `zephyr-app` into `.`); one without fails instead of asking (`tan scaffold` needs `--name`). The same rule applies unasked when stdin or stderr is not a terminal \u2014 piped, redirected, or a CI runner\n\n --ci\n CI mode: implies non-interactive and disables color\n\n -h, --help\n Print help (see a summary with '-h')\n\n -V, --version\n Print version\n", + "tests/parity/test_run_oracle_parity.py::test_run_help_is_not_the_same_flag_set_as_build_or_flash#0": "Build the project, then run it: execute the produced native_sim binary for a host target, or flash a hardware target (native)\n\nUsage: tan.exe run [OPTIONS]\n\nOptions:\n --flash\n Program the board after building (hardware targets only). Required opt-in: without it, `run` on a hardware project builds and reports but never flashes. Ignored for a native_sim/host target, which always runs the produced binary and never flashes\n\n --project \n Project root (defaults to current directory)\n\n --board-yaml \n Explicit board.yaml path (overrides project resolution)\n\n --core \n With `--flash`, flash only the slice with this `core_id` (forwarded verbatim to the native flash path's `--core`)\n\n --sdk-root \n alp-sdk checkout root\n\n --target \n Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf)\n\n --all\n Run command against all relevant targets\n\n --format \n Output format\n\n Possible values:\n - text: Human-readable text (default)\n - json: Machine-readable JSON envelope\n \n [default: text]\n\n --verbose\n Emit additional diagnostic detail\n\n --quiet\n Suppress non-essential output\n\n --no-color\n Disable ANSI color in text output\n\n --non-interactive\n Never prompt. A command with a documented default takes it (`tan init` scaffolds `zephyr-app` into `.`); one without fails instead of asking (`tan scaffold` needs `--name`). The same rule applies unasked when stdin or stderr is not a terminal \u2014 piped, redirected, or a CI runner\n\n --ci\n CI mode: implies non-interactive and disables color\n\n -h, --help\n Print help (see a summary with '-h')\n\n -V, --version\n Print version\n", + "tests/parity/test_run_oracle_parity.py::test_run_help_is_not_the_same_flag_set_as_build_or_flash#1": "Build the project natively: consume the SDK's emitted build plan, materialise its files, then run each per-core slice's command directly\n\nUsage: tan.exe build [OPTIONS]\n\nOptions:\n --plan\n Show the build plan (consumed from the SDK's `--emit build-plan`) and exit without building\n\n --project \n Project root (defaults to current directory)\n\n --board-yaml \n Explicit board.yaml path (overrides project resolution)\n\n --plan-from \n Read the build plan from a JSON file instead of invoking the SDK. Implies `--plan`. Use this to consume `alp_orchestrate.py --emit build-plan` output instead of the live emit (which is the default plan source)\n\n --materialise\n Materialise the plan: write its generated files (shared artefacts + per-slice config) to disk under the build root, instead of just showing the plan. With no `--plan-from`, the plan is fetched live from the SDK\n\n --sdk-root \n alp-sdk checkout root\n\n --native\n Build natively: consume the plan, materialise its files, then run each slice's command (`west` / `bitbake` / `cmake`) sequentially. This is the default; the flag is kept as an explicit opt-in\n\n --target \n Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf)\n\n --all\n Run command against all relevant targets\n\n --manifest\n Show the system manifest \u2014 the post-build IDE/tool contract (`build/system-manifest.yaml`): per-core slices + ipc + helper MCUs. Without `--manifest-from`, asks the SDK for the projection (`alp_orchestrate.py --emit system-manifest`)\n\n --format \n Output format\n\n Possible values:\n - text: Human-readable text (default)\n - json: Machine-readable JSON envelope\n \n [default: text]\n\n --manifest-from \n Read the system manifest from a YAML file instead of invoking the SDK (e.g. the `build/system-manifest.yaml` a build already wrote). Implies `--manifest`\n\n --no-auto-bootstrap\n Never bootstrap implicitly. By default a text-mode build with no Zephyr workspace (or a stale one) runs `tan bootstrap` first, which clones Zephyr + the HALs beside the SDK checkout and takes minutes. Use this to keep `tan build` to building and get the readiness report instead\n\n --verbose\n Emit additional diagnostic detail\n\n --pristine\n Force-wipe every slice's build dir before dispatch, regardless of the recorded SDK-switch stamp (tan-cli#163) \u2014 the manual counterpart to the automatic sdk-switch-pristine wipe, for a stale build dir the stamp heuristic doesn't (or can't yet) catch. Same wipe, same two safety guards (an explicit `-d`/`--build-dir` in the slice's own command, or a plan cwd outside `build/`): this never touches a dir tan can't vouch for, same as the automatic path. A slice the wipe declines \u2014 for either guard, or because the dir was never configured \u2014 says so on the envelope and in text (`build.pristine-skipped`, tan-cli#183), so \"pristine\" never silently means \"incremental\"\n\n --quiet\n Suppress non-essential output\n\n --no-color\n Disable ANSI color in text output\n\n --non-interactive\n Never prompt. A command with a documented default takes it (`tan init` scaffolds `zephyr-app` into `.`); one without fails instead of asking (`tan scaffold` needs `--name`). The same rule applies unasked when stdin or stderr is not a terminal \u2014 piped, redirected, or a CI runner\n\n --ci\n CI mode: implies non-interactive and disables color\n\n -h, --help\n Print help (see a summary with '-h')\n\n -V, --version\n Print version\n", + "tests/parity/test_run_oracle_parity.py::test_run_help_is_not_the_same_flag_set_as_build_or_flash#2": "Flash every slice + helper MCU from `build/system-manifest.yaml` onto the device in `boot_order` (native)\n\nUsage: tan.exe flash [OPTIONS] [APP_PATH]\n\nArguments:\n [APP_PATH]\n Application source directory (default: `.`, the current directory). `build_root` defaults to `/build`. Optional positional (the unified app-path convention: `tan flash` from the app dir needs no argument, matching the retired Python `app_path` default)\n \n [default: .]\n\nOptions:\n --build-root \n Override the build root holding `system-manifest.yaml` (default: `/build`)\n\n --project \n Project root (defaults to current directory)\n\n --board-yaml \n Explicit board.yaml path (overrides project resolution)\n\n --dry-run\n Print the flash command each backend WOULD run and return ok without spawning; also bypasses the required-tool PATH gate\n\n --core \n Flash only the slice with this `core_id` (skips every other slice AND all helpers)\n\n --sdk-root \n alp-sdk checkout root\n\n --helper \n Flash only the helper MCU with this name (skips ALL slices and every other helper)\n\n --target \n Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf)\n\n --all\n Run command against all relevant targets\n\n --skip-missing-tools\n When a backend's required tools are all absent from PATH, warn + skip the entry instead of failing it. No effect under `--dry-run`\n\n --format \n Output format\n\n Possible values:\n - text: Human-readable text (default)\n - json: Machine-readable JSON envelope\n \n [default: text]\n\n --verbose\n Emit additional diagnostic detail\n\n --quiet\n Suppress non-essential output\n\n --no-color\n Disable ANSI color in text output\n\n --non-interactive\n Never prompt. A command with a documented default takes it (`tan init` scaffolds `zephyr-app` into `.`); one without fails instead of asking (`tan scaffold` needs `--name`). The same rule applies unasked when stdin or stderr is not a terminal \u2014 piped, redirected, or a CI runner\n\n --ci\n CI mode: implies non-interactive and disables color\n\n -h, --help\n Print help (see a summary with '-h')\n\n -V, --version\n Print version\n" +} diff --git a/python/tests/parity/test_build_sdk_root_oracle_parity.py b/python/tests/parity/test_build_sdk_root_oracle_parity.py new file mode 100644 index 00000000..cc6a34c4 --- /dev/null +++ b/python/tests/parity/test_build_sdk_root_oracle_parity.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan build --sdk-root ` against the shipped Rust oracle. + +Closes the divergence `test_run_oracle_parity.py`'s +`test_run_sdk_root_invalid_is_a_known_divergence_from_the_oracle` documents +for `run` (which shares its own copy of the same ladder-resolution code and +is unaffected by this fix, tan-cli#257/#258 scope): `build_cmd.build` +resolved a bogus explicit `--sdk-root` unvalidated, fell through to +`_emit_plan`'s NEXT missing thing (`no board.yaml found`), and reported an +`sdk` key the oracle never emits on this path. Live, not frozen-replay -- +spawns both binaries unconditionally whenever an oracle is present, mirroring +`test_run_oracle_parity.py`'s own `_ORACLE_REQUIRED` cases, so a regression +here cannot hide behind a stale fixture.""" +import pytest + +from .oracle import _run, python_command, rust_binary + +RUST = rust_binary() + +_ORACLE_REQUIRED = pytest.mark.skipif( + RUST is None, + reason="needs a built Rust tan; run `cargo build --bin tan` (or set TAN_RUST_BINARY)", +) + + +@_ORACLE_REQUIRED +def test_build_sdk_root_invalid_matches_the_oracle(tmp_path): + home = tmp_path / "home" + work = tmp_path / "root" + work.mkdir() + argv = ["build", "--format", "json", "--sdk-root", "./nowhere"] + r_code, r_out = _run([RUST], argv, work, home) + p_code, p_out = _run(python_command(), argv, work, home) + + assert r_code == p_code == 1 + assert [i["code"] for i in r_out["issues"]] == ["build.plan-unavailable"] + assert [i["code"] for i in p_out["issues"]] == ["build.plan-unavailable"] + # Neither side reports an `sdk` key: an unresolvable explicit --sdk-root + # is treated as no root at all, not a resolved-but-wrong one. + assert "sdk" not in r_out + assert "sdk" not in p_out + # Both refuse for the SDK, not for the (also-missing) board.yaml -- the + # message text still differs (allowed; only the machine-contract fields + # are pinned here), but neither may mention board.yaml. + assert "board.yaml" not in r_out["issues"][0]["message"] + assert "board.yaml" not in p_out["issues"][0]["message"] + assert r_out["data"] is None + assert p_out["data"] is None diff --git a/python/tests/parity/test_flash_oracle_parity.py b/python/tests/parity/test_flash_oracle_parity.py index 9a100f87..137d8f5c 100644 --- a/python/tests/parity/test_flash_oracle_parity.py +++ b/python/tests/parity/test_flash_oracle_parity.py @@ -405,6 +405,76 @@ def work_dir(tmp_path_factory): _HOST_ANCHORED_ABSOLUTE_CASES = frozenset({"absolute-artefact-passes-through"}) +#: Case IDs whose expected message (or whose pass/fail shape) depends on a +#: LIVE tool-presence probe against PATH -- either `plan_yocto_wic`'s own +#: `which("bmaptool")`/`which("dd")` (`tan/core/flash_plan.py:1002-1056`), or +#: the required-tool gate `tool_gate` (`flash_plan.py:1524-1545`, reached via +#: `doctor_cmd.on_path` at `flash_cmd.py:879-882`). The frozen fixture +#: recorded whichever tool inventory the CAPTURE host happened to have -- +#: `dd` present/`bmaptool` absent for the three yocto cases, `west` absent +#: for the two `zephyr_west_flash` ones below -- so replaying on a host with +#: a DIFFERENT inventory (this box, for one: real `dd`, no `bmaptool`, and a +#: broken but PATH-resolvable `west` shim) makes the port pick/find a +#: different tool and diffs on text that is not a port bug at all +#: (tan-cli#313). +#: +#: `tool_gate` is bypassed ONLY under `--dry-run` or an empty `requires` +#: (its own docstring) -- it is LIVE for every other case that reaches it. +#: That is NOT every other case in `CASES`, though: three more non-dry-run +#: cases never reach `tool_gate` at all, refused by an earlier check in +#: `flash_cmd.py`'s dispatch order (traced directly, not inferred): +#: * `no-artefact-real-run-fails` -- fails the empty-artefact check +#: BEFORE `tool_gate` (`flash_cmd.py:856-860`). +#: * `flash-args-tbd-mapping` -- skips on `flash_args_has_tbd` BEFORE +#: `tool_gate` (`flash_cmd.py:811-817`). +#: * `sdk-root-invalid` -- refused at SDK-root resolution, before the +#: manifest is even read (`flash_cmd.py:1163-1175`), nowhere near a +#: backend or `tool_gate`. +#: See `_pin_tool_inventory` for why the five pinned below DO need it. +_TOOL_PROBE_PINNED_CASES = frozenset( + { + "empty-boot-order-sorts-and-helpers-last", + "yocto-unconfirmed-is-planned-not-ok", + "yocto-alias-method-resolves", + "missing-tool-fails", + "missing-tool-skips-with-flag", + } +) + + +def _pin_tool_inventory(work_dir) -> str: + """A scratch PATH holding exactly one stand-in tool, `dd` -- matching what + the fixture's capture host had (`dd`, no `bmaptool`, no `west`) -- for + `python_env_overrides` to hand to the PYTHON side alone (tan-cli#313). + + Serves two different probes with the one stub dir: `plan_yocto_wic`'s + `which("bmaptool")`/`which("dd")` (the three yocto case IDs), where `dd` + being FOUND is the point, and `tool_gate`'s `west` probe (the two + `missing-tool-*` case IDs), where `dd`'s presence is irrelevant and + `west` simply not being anywhere on this replaced PATH is what the + frozen "none found" answer needs. + + Replaces PATH outright rather than prepending: `doctor_cmd.on_path` + (what `plan_yocto_wic`'s and `tool_gate`'s `which` callables both + resolve to) walks every directory looking for a name match, so + prepending a `dd`-only directory ahead of the replay host's real PATH + would still let a REAL `bmaptool` or `west` further down it be found -- + which is exactly the host-dependence this fixes. The stub's own content + is never read: every case that pins this is a `--dry-run` preview, an + unconfirmed "would run" plan, or the required-tool gate's own refusal/ + skip message, so `dd` is only ever named in a message, never spawned. + `chmod` is a no-op on Windows (no execute bit there), where + `os.access(path, os.X_OK)` accepts any existing file -- covered by + `doctor_cmd.on_path`'s own docstring. + """ + stub_dir = work_dir / "tool-stub" + stub_dir.mkdir() + dd_stub = stub_dir / "dd" + dd_stub.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + dd_stub.chmod(0o755) + return str(stub_dir) + + @pytest.mark.parametrize("case_id, manifest, extra", CASES, ids=[c[0] for c in CASES]) def test_flash_matches_the_rust_oracle(case_id, manifest, extra, work_dir): if case_id in _HOST_ANCHORED_ABSOLUTE_CASES and not oracle_fixtures.REPLAY_IS_CAPTURE_PLATFORM: @@ -417,7 +487,16 @@ def test_flash_matches_the_rust_oracle(case_id, manifest, extra, work_dir): (work_dir / "build" / "system-manifest.yaml").write_text( manifest, encoding="utf-8", newline="" ) - result = compare(_argv(extra), work_dir, surface=ENVELOPE, home=work_dir) + python_env_overrides = ( + {"PATH": _pin_tool_inventory(work_dir)} if case_id in _TOOL_PROBE_PINNED_CASES else None + ) + result = compare( + _argv(extra), + work_dir, + surface=ENVELOPE, + home=work_dir, + python_env_overrides=python_env_overrides, + ) assert result.matches, f"{case_id}: " + "; ".join(result.diffs) @@ -470,6 +549,22 @@ def test_a_real_spawn_diffs_including_the_captured_failure_tail(work_dir): Also skipped where the local `dd` is not the implementation the fixture captured -- see `_dd_matches_the_captured_implementation`. + + Also skipped where `bmaptool` IS present: `plan_yocto_wic`'s + `if bmaptool or (planning_only and not dd)` (`flash_plan.py:1023-1026`) + picks `bmaptool` whenever it is found on PATH, unconditionally -- + `planning_only` narrows nothing here, since the check is a bare `or`. + This is an ordinary Yocto dev host (`apt install bmap-tools` is the + documented way to get the preferred tool), not an exotic one, and + unlike the yocto CASES above this test is not in `_TOOL_PROBE_PINNED_CASES` + and cannot be: it actually SPAWNS the resolved tool (that is the test's + whole subject, its captured stderr tail), and a `dd` stand-in that only + NAMES the tool would defeat that -- pinning would need a stub `dd` + faithful enough to reproduce the fixture's exact captured failure text, + which is just `_dd_matches_the_captured_implementation`'s own job + restated. Measured: with a `bmaptool` stub on PATH the python side spawns + it (exits `rc=1`, no captured tail) while the frozen fixture still names + `dd`'s tail -- tan-cli#313 at this call site too. """ if shutil.which("dd") is None: pytest.skip("no `dd` on PATH; nothing to spawn") @@ -480,6 +575,12 @@ def test_a_real_spawn_diffs_including_the_captured_failure_tail(work_dir): "to open '': No such file`); the tail this test compares is the " "SPAWNED TOOL's text, not tan's, so that diff is not a port defect" ) + if shutil.which("bmaptool") is not None: + pytest.skip( + "a host with bmaptool on PATH plans that tool instead of dd " + "(flash_plan.py:1023-1026 prefers it unconditionally); this test's " + "subject is the SPAWNED dd's captured failure tail, not bmaptool's" + ) (work_dir / "build" / "system-manifest.yaml").write_text( _slice("yocto_wic", "{target: /dev/sdb}"), encoding="utf-8", newline="" ) diff --git a/python/tests/parity/test_oracle_parity.py b/python/tests/parity/test_oracle_parity.py index 232e4ec6..a9c4e1e4 100644 --- a/python/tests/parity/test_oracle_parity.py +++ b/python/tests/parity/test_oracle_parity.py @@ -22,6 +22,7 @@ mis-classified as "not ported" forever. """ import json +import os import shutil import subprocess import sys @@ -39,6 +40,7 @@ VERSION, _run, compare, + empty_tool_inventory, missing_for_live, narrow_plan, normalise_path_separators, @@ -63,8 +65,9 @@ #: Every case: argv, the surface it is scoped to, and -- when the port cannot #: satisfy it yet -- why. A ``None`` reason means the case runs for real. CASES = [ - # The extension's acceptance probe. Compared by SHAPE: 0.5.0-dev vs - # 0.4.1-dev is a deliberate, permanent difference (python/tan/version.py). + # The extension's acceptance probe. Compared by SHAPE: the port's + # 0.5.0-rc3 (python/tan/version.py) vs the oracle's 0.4.1 (Cargo.toml) is + # a deliberate, permanent difference. (["--version"], VERSION, None), # A usage error must put its diagnosis on stderr and leave stdout EMPTY -- # the extension parses stdout whole, so one stray byte breaks it. clap and @@ -84,15 +87,6 @@ ENVELOPE, None, ), - # …and `--format` BEFORE the subcommand, which is how the four goldens - # invoke it (clap's `global = true`). Worth its own case: Click gives the - # group only what precedes the subcommand, so this position is a separate - # code path in the port and not in Rust. - ( - ["--format", "json", "debug-config", "--target-kind", "native-host", "--preview"], - ENVELOPE, - None, - ), # The first case that compares a whole SUCCESS envelope from a ported # command, not a usage error: `presets` with nothing resolvable exits 0 and # reports the frozen `presets.sdk-root-unresolved` warning plus the built-in @@ -205,11 +199,17 @@ def test_west_forward_matches_rust(verb, work_dir, tmp_path): so `data.westCwd` actually goes through the workspace-walk branch (not just the already-posix `--project` echo) -- the branch where a bare `str(PathLikeObject)` re-renders with the platform separator on Windows - and breaks the envelope's platform-identical-path contract. Neither side - has a real `west` on PATH here, so both report the same launch-error - envelope; that error envelope still carries `data.westCommand`/`westCwd`/ - `args`, which is exactly what a westCwd or args-capture regression would - move. + and breaks the envelope's platform-identical-path contract. The frozen + fixture was captured on a host with no `west` on PATH at all, so the rust + side's (frozen) answer is the "west not found on PATH" launch error; + `python_env_overrides` pins the PYTHON side's PATH to match that same + absence, rather than whatever this replay host happens to have installed + -- on any host with a PATH-resolvable `west`, working or not, the python + side would otherwise genuinely launch it and diverge on ITS output + instead of reporting the same launch error (tan-cli#324; the identical class of bug + `_pin_tool_inventory` fixed for the yocto_wic flash cases, tan-cli#313). + That error envelope still carries `data.westCommand`/`westCwd`/`args`, + which is exactly what a westCwd or args-capture regression would move. """ (work_dir / ".west").mkdir() # `--format json` sits BEFORE the forwarded `--core`/`-b` flags on purpose: @@ -232,30 +232,47 @@ def test_west_forward_matches_rust(verb, work_dir, tmp_path): "-b", "some_board", ] - result = compare(argv, cwd=work_dir, home=tmp_path / "home") + result = compare( + argv, + cwd=work_dir, + home=tmp_path / "home", + python_env_overrides={"PATH": empty_tool_inventory(tmp_path)}, + ) assert result.matches, "\n".join(result.diffs) @LIVE_GATE @pytest.mark.parametrize( - "target,server", + "target,server,expected_pre_launch_task", [ # J-Link resolves `device` + `gdbPath`; OpenOCD resolves # `serverpath`/`searchDir`/`configFiles`; pyOCD resolves NOTHING (the # board registers no such runner) and must keep its placeholder AND gain # the "registers no runner" note; native-host must take the native_sim # slice's sibling `.exe`, not the first `os: zephyr` slice's ELF. - ("zephyr-mcu", "jlink"), - ("zephyr-mcu", "openocd"), - ("zephyr-mcu", "pyocd"), - ("native-host", "none"), + # + # `expected_pre_launch_task` is tan-cli#138's restored default, a + # DELIBERATE, PERMANENT divergence from the frozen `crates/` oracle: + # #138 predates the oracle's freeze and it never emits this key. + # Measured live against `tan --format json debug-config ...` for every + # combination below -- not inferred from source. + ("zephyr-mcu", "jlink", "alp: build active target"), + ("zephyr-mcu", "openocd", "alp: build active target"), + ("zephyr-mcu", "pyocd", "alp: build active target"), + ("native-host", "none", "alp: build native_sim target"), ], ) -def test_debug_config_resolution_matches_rust(target, server, work_dir, tmp_path): +def test_debug_config_resolution_matches_rust(target, server, expected_pre_launch_task, work_dir, tmp_path): """The `` overlay read off this project's OWN build output - (#66/#83), diffed against the oracle. `--preview` only: `compare` runs both - binaries in the SAME cwd, so a write-mode case would have the second run - merge into what the first one wrote.""" + (#66/#83), diffed against the oracle. `--preview` only: both sides run in + the SAME cwd, so a write-mode case would have the second run merge into + what the first one wrote. + + NOT a plain `compare()` (tan-cli#138 vs the frozen oracle): the restored + `preLaunchTask` default is a permanent divergence `compare()`'s whole-key + equality would flag as a false failure, so this does `compare()`'s own + scrub/normalise recipe by hand, strips `preLaunchTask` from the python + side after asserting its value, and diffs everything else.""" root = str(work_dir).replace("\\", "/") build = work_dir / "build" build.mkdir() @@ -266,13 +283,70 @@ def test_debug_config_resolution_matches_rust(target, server, work_dir, tmp_path zephyr.mkdir(parents=True) (zephyr / "runners.yaml").write_text(PARITY_RUNNERS, encoding="utf-8") - result = compare( - ["debug-config", "--target-kind", target, "--server", server, - "--preview", "--format", "json"], - cwd=work_dir, - home=tmp_path / "home", - ) - assert result.matches, "\n".join(result.diffs) + argv = ["debug-config", "--target-kind", target, "--server", server, "--preview", "--format", "json"] + home = tmp_path / "home" + roots = (work_dir, home) + r_code, r_out = rust_run(argv, work_dir, home, scrub_roots=roots) + p_code, p_out = _run(python_command(), argv, work_dir, home) + p_out = oracle_fixtures.scrub(p_out, *roots) + r_out = normalise_path_separators(r_out) + p_out = normalise_path_separators(p_out) + r_out = oracle_fixtures.normalise_scrubbed_path_separators(r_out) + p_out = oracle_fixtures.normalise_scrubbed_path_separators(p_out) + + assert r_code == p_code, (r_code, p_code, r_out, p_out) + r_config = r_out.get("data", {}).get("configuration") or {} + assert "preLaunchTask" not in r_config, r_config + p_config = p_out.get("data", {}).get("configuration") or {} + assert p_config.get("preLaunchTask") == expected_pre_launch_task, p_config + + p_out_stripped = json.loads(json.dumps(p_out)) # deep copy + del p_out_stripped["data"]["configuration"]["preLaunchTask"] + diffs = [ + f"{key}: rust={r_out.get(key)!r} python={p_out_stripped.get(key)!r}" + for key in sorted(set(r_out) | set(p_out_stripped)) + if r_out.get(key) != p_out_stripped.get(key) + ] + assert not diffs, "\n".join(diffs) + + +@LIVE_GATE +def test_debug_config_native_host_preview_global_format_matches_rust(work_dir, tmp_path): + """`--format` BEFORE the subcommand (`["--format", "json", "debug-config", + "--target-kind", "native-host", "--preview"]`), which is how the four + `debug-config` goldens invoke it (clap's `global = true`). Worth its own + case: Click gives the group only what precedes the subcommand, so this + position is a separate code path in the port and not in Rust. Used to be a + plain `CASES` entry (whole-envelope `compare()`), but tan-cli#138's + restored `preLaunchTask` default is a DELIBERATE, PERMANENT divergence + from the frozen `crates/` oracle (which predates #138 and never emits the + key) -- see `test_debug_config_resolution_matches_rust`'s own docstring + for why this needs the manual `rust_run`/`_run` diff instead.""" + argv = ["--format", "json", "debug-config", "--target-kind", "native-host", "--preview"] + home = tmp_path / "home" + roots = (work_dir, home) + r_code, r_out = rust_run(argv, work_dir, home, scrub_roots=roots) + p_code, p_out = _run(python_command(), argv, work_dir, home) + p_out = oracle_fixtures.scrub(p_out, *roots) + r_out = normalise_path_separators(r_out) + p_out = normalise_path_separators(p_out) + r_out = oracle_fixtures.normalise_scrubbed_path_separators(r_out) + p_out = oracle_fixtures.normalise_scrubbed_path_separators(p_out) + + assert r_code == p_code, (r_code, p_code, r_out, p_out) + r_config = r_out.get("data", {}).get("configuration") or {} + assert "preLaunchTask" not in r_config, r_config + p_config = p_out.get("data", {}).get("configuration") or {} + assert p_config.get("preLaunchTask") == "alp: build native_sim target", p_config + + p_out_stripped = json.loads(json.dumps(p_out)) # deep copy + del p_out_stripped["data"]["configuration"]["preLaunchTask"] + diffs = [ + f"{key}: rust={r_out.get(key)!r} python={p_out_stripped.get(key)!r}" + for key in sorted(set(r_out) | set(p_out_stripped)) + if r_out.get(key) != p_out_stripped.get(key) + ] + assert not diffs, "\n".join(diffs) @LIVE_GATE @@ -550,10 +624,14 @@ def test_validate_board_yaml_missing_guard_matches_the_oracle_at_exit_2(work_dir @LIVE_GATE def test_validate_no_sdk_guard_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): """`board.yaml` present, no SDK resolvable: the oracle's pre-spawn guard - answers exit 2 `validate.sdk-root-unresolved`; the port answers exit 1 + answers exit 2 `validate.sdk-root-unresolved`; the port answers exit 2 `validate.spawn-not-implemented` (the full spawn path is simply not ported yet) -- the genuine, tracked divergence `validate_cmd.py`'s own - docstring calls tan-cli#262. Pinned as a KNOWN divergence, following the + docstring calls tan-cli#262. Both exit 2 since #262 landed: the port moved + off exit 1 deliberately ("no verdict available" is the VALIDATOR's verdict, + not a tan crash), so only the issue code is the real divergence now. Both + stay pinned rather than narrowed to "issue code only", which would hide + that coincidence going away. Pinned as a KNOWN divergence, following the same exclude-and-pin convention `test_init_sdk_root_flag_pin_is_a_known_ divergence_from_the_oracle` above uses, rather than asserted as parity that does not exist. @@ -569,7 +647,7 @@ def test_validate_no_sdk_guard_is_a_known_divergence_from_the_oracle(work_dir, t r_code, r_out = rust_run(argv, work_dir, home, scrub_roots=(work_dir, home)) p_code, p_out = _run(python_command(), argv, work_dir, home) assert (r_code, [i["code"] for i in r_out["issues"]]) == (2, ["validate.sdk-root-unresolved"]) - assert (p_code, [i["code"] for i in p_out["issues"]]) == (1, ["validate.spawn-not-implemented"]) + assert (p_code, [i["code"] for i in p_out["issues"]]) == (2, ["validate.spawn-not-implemented"]) @LIVE_GATE @@ -597,6 +675,376 @@ def test_sdk_switch_unresolvable_version_is_a_known_divergence_from_the_oracle(w assert (p_code, [i["code"] for i in p_out["issues"]]) == (1, ["sdk.not-ported"]) +# --- v0.6.0's named command-surface parity ---------------------------------- +# +# v0.6.0's own milestone goal states, verbatim: "Full command-surface parity +# with the v0.4.1 oracle: model, new-som, monitor, faultdecode, the +# introspection set, renode, and the seven entirely-unported verbs." Nothing +# above this point in the file ever runs any of those verbs -- this section is +# what actually reads that claim, one case per verb, against a REAL run of the +# oracle (never inferred from `crates/` or a docstring). +# +# These do NOT go through `compare()`/`rust_run()`: both replay a COMMITTED +# fixture by default (`oracle_fixtures.resolve`), and adding a fixture entry +# for a brand-new case is a separate, deliberate act with its own capture +# recipe (`oracle_fixtures/PROVENANCE.txt`) -- out of scope for this change. +# Instead these spawn `RUST` directly, every run, skipped only when no oracle +# binary is built (`_ORACLE_REQUIRED`, keyed on BINARY PRESENCE, not +# `TAN_PARITY_LIVE` like `LIVE_GATE` above): reusing `LIVE_GATE` here would NOT +# skip when `RUST is None`, since `missing_for_live` only ever fires under +# `TAN_PARITY_LIVE=1`, and would instead crash inside `subprocess.run([None, +# ...])`. `ci.yml`'s `python` job never runs `cargo build`, so `RUST is None` +# there and these cleanly skip; `parity.yml`'s `python-tests` job DOES +# (`cargo build --locked --bin tan`), so there -- and on any host with +# `target/{release,debug}/tan` already built, this one included -- these +# genuinely spawn both binaries fresh, in the SAME `work_dir`/`home`, and diff +# them for real. Because both sides share that one scratch `work_dir`, an +# embedded absolute path is already byte-comparable with no +# `oracle_fixtures.scrub` needed, unlike the frozen-replay cases above (whose +# fixture was captured from a DIFFERENT scratch dir than any replay). +# +# `_ORACLE_REQUIRED` skips on binary ABSENCE only -- never on a resolved binary +# being the WRONG one. `rust_binary()` picks the MOST RECENTLY BUILT of +# target/{release,debug}/tan (its own docstring), so a resolved-but-wrong +# binary today means either an inverted or TIED mtime between the two +# profiles (a tie is refused outright inside `rust_binary()` itself -- see +# that function) or an explicit TAN_RUST_BINARY naming the wrong one. This +# comment used to describe an OLDER rule -- a fixed release-over-debug +# preference -- and the failure that rule caused: measured on a real host, a +# stale `target/release/tan` (`tan 0.1.1`, weeks old) sat next to a fresh +# `target/debug/tan` (`tan 0.4.1`), `RUST` silently bound to the stale one +# because release was tried unconditionally regardless of either file's age, +# and every case below -- which, unlike the `LIVE_GATE` cases above, has no +# frozen fixture to fall back to -- measured itself against a binary that +# predates half the commands it runs: 7 of these failed, with no signal that +# the oracle, not the port, was wrong. Turning that into a SKIP (e.g. +# widening `_ORACLE_REQUIRED`'s condition to also skip on a version mismatch) +# would reopen exactly the hole `missing_for_live`'s own docstring refuses -- +# "a quiet skip here would hide exactly the gap that function exists to +# surface" (tan-cli#272) -- so `pinned_oracle`, now a session-scoped, autouse +# fixture in `conftest.py` that every module under `tests/parity/` inherits +# (not just this section), FAILS the run instead, loudly, naming the +# mismatch. `_ORACLE_REQUIRED` below composes only the presence skip: the +# content check no longer needs opting into per case. + + +def _oracle_required(fn): + """`@_ORACLE_REQUIRED`'s actual decorator: skips on binary ABSENCE only. + The version-WRONGNESS check (`pinned_oracle`) is a session-scoped, + autouse fixture in `conftest.py` now, so every case tagged + `@_ORACLE_REQUIRED` gets it for free the same way every OTHER module + under `tests/parity/` does -- nothing here opts it in by hand any more.""" + fn = pytest.mark.skipif( + RUST is None, + reason="needs a built Rust tan; run `cargo build --bin tan` (or set TAN_RUST_BINARY)", + )(fn) + return fn + + +_ORACLE_REQUIRED = _oracle_required + + +@_ORACLE_REQUIRED +@pytest.mark.parametrize( + "argv,exit_code", + [ + (["explain", "--format", "json"], 0), + (["explain", "--template", "bogus-template", "--format", "json"], 1), + (["explain", "--target", "bogus-target", "--format", "json"], 1), + ], + ids=["overview", "unknown-template", "unknown-target"], +) +def test_explain_matches_the_oracle(argv, exit_code, work_dir, tmp_path): + """tan-cli#257 (the introspection set). `explain` reads no board.yaml and + no alp-sdk checkout at all -- it is a static topic index over the + template/target catalogues baked into both binaries -- and its envelope + is byte-identical on every invocation measured here: the overview, an + unknown ``--template``, and an unknown ``--target``. + + ``exit_code`` is PINNED per case (0 for the overview, 1 for each + unknown-topic refusal), measured directly rather than left as a bare + ``r_code == p_code``: that comparison, plus ``oracle._run``'s own + degrade-on-unparseable-stdout fallback (``{'__raw__': ...}``), lets two + binaries that both wrote NOTHING to stdout (say, both crashing before + printing) compare equal at exit ``0 == 0`` having measured nothing at + all. The explicit non-empty, non-``__raw__`` envelope check below closes + that the rest of the way.""" + home = tmp_path / "home" + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_code == p_code == exit_code + assert r_out and "__raw__" not in r_out, r_out + assert p_out and "__raw__" not in p_out, p_out + assert r_out == p_out + + +# No `image`-missing-manifest case here, unlike its introspection-set siblings +# above and below: `test_image_missing_manifest` in `test_image_size_oracle.py` +# already covers this exact surface (exit 1, byte-identical envelope, +# including the message's embedded absolute path) and does so with NO +# divergence to pin -- `image`'s refusal message carries no OS-error tail to +# normalise or narrow, unlike `size` just below. A case living here would +# duplicate that assertion verbatim while adding nothing (measured: the two +# read byte-for-byte identical envelopes on this oracle), so it was dropped +# rather than kept as a second copy of the same check. +# +# Honestly, the drop gives up two things `size`'s own case below keeps, and +# both are acceptable for the identical reason -- no divergence exists for +# `image` to hide from either axis: +# +# * LIVE-SPAWN coverage. `test_image_missing_manifest` runs through +# `assert_parity` -> `_rust_run` -> `oracle_fixtures.resolve`, which REPLAYS +# a frozen fixture unless `TAN_PARITY_LIVE=1` is set. `size`'s case here +# uses `@_ORACLE_REQUIRED`, which spawns the oracle live unconditionally +# whenever a binary is present. Dropping `image` here means it is never +# exercised by THIS file's unconditional-live mode, only by a frozen replay +# or an opt-in live run elsewhere. +# * DEFAULT-BUILD-ROOT coverage. `test_image_missing_manifest` always passes +# an explicit `--build-root br`; `size`'s case here passes no +# `--build-root` at all, resolving the DEFAULT `work_dir/build/system- +# manifest.yaml`. `image`'s missing-manifest path is never measured against +# the default build root anywhere in this repo. +# +# Both gaps are safe to leave open because they are gaps in HOW the answer is +# produced, not in WHAT could go wrong: `image`'s refusal message is a fixed +# string plus an embedded path with no OS-error tail, so it cannot drift +# between a frozen fixture and a live run, or between an explicit and a +# default build root, the way `size`'s OS-`errno` rendering can. A live, +# default-build-root `image` case would measure the identical envelope this +# file already confirmed byte-identical under `--build-root br`, adding +# coverage of the harness's own plumbing, not of `image` itself. + +@_ORACLE_REQUIRED +def test_renode_no_sdk_matches_the_oracle(work_dir, tmp_path): + """tan-cli#77. ``renode`` with no alp-sdk resolvable and no manifest is + byte-identical -- the whole ``data`` placeholder shape (empty sku/repl/ + resc/elf, the derived ``logPath``) included.""" + home = tmp_path / "home" + argv = ["renode", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_code == p_code == 1 + assert r_out == p_out + + +@_ORACLE_REQUIRED +def test_size_missing_manifest_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#257 (the introspection set). Exit code and issue CODE match; + the message's trailing OS-error text does not, and permanently cannot -- + it is Rust's ``io::Error`` Display ("No such file or directory (os error + 2)") against Python's ``OSError`` str ("[Errno 2] No such file or + directory: ''"), two runtimes rendering the identical ``ENOENT``. + Pinned literally on BOTH the matching prefix and the diverging tail, per + this file's own rule against narrowing a comparison down to "exit code + only" to make it pass -- a change to either rendering, or the two + converging, must fail this test rather than pass it silently. + + Overlaps `test_size_missing_manifest` in `test_image_size_oracle.py` on + the SAME setup (an empty ``build/system-manifest.yaml``-less project) but + NOT on what it asserts: that test's `_normalise` collapses this exact + OS-error tail into a placeholder (``run \\`tan build\\` first + ().``) before comparing, deliberately treating the wording as + immaterial -- this test asserts the opposite, pinning the literal, + un-normalised text on BOTH sides as the divergence itself. It is also, + unlike that one, an unconditional LIVE spawn (`_ORACLE_REQUIRED` is keyed + on binary presence, not `TAN_PARITY_LIVE`; see the module comment above + the v0.6.0 section), where the counterpart replays a committed fixture by + default and only spawns the oracle under `TAN_PARITY_LIVE=1`.""" + home = tmp_path / "home" + argv = ["size", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_code == p_code == 1 + assert [i["code"] for i in r_out["issues"]] == ["size.manifest-unavailable"] + assert [i["code"] for i in p_out["issues"]] == ["size.manifest-unavailable"] + # Both sides render this path the same way, and it is NOT `str(Path)`: + # the project root arrives as the POSIX-ish string the caller passed and + # is kept verbatim, then the `build/system-manifest.yaml` tail is joined + # with the platform separator -- so on Windows the real message carries + # `C:/.../root\build\system-manifest.yaml`, mixed on purpose. Rebuilding + # it as `str(work_dir / ...)` gives an all-backslash path that NEITHER + # binary emits: a defect in the expectation, not in either side. The two + # agree with each other here, which is the thing this test measures. + manifest_path = os.path.join(work_dir.as_posix(), "build", "system-manifest.yaml") + prefix = f"no system-manifest.yaml at {manifest_path}; run `tan build` first (" + r_message = r_out["issues"][0]["message"] + p_message = p_out["issues"][0]["message"] + # The Rust tail is PLATFORM-dependent, and pinning only the POSIX + # rendering made this a Linux-only pass -- it reddens on Windows against + # a completely healthy tree. The missing component here is the `build` + # DIRECTORY, not merely the leaf file, and Windows distinguishes those + # two: it returns ERROR_PATH_NOT_FOUND (3), "The system cannot find the + # path specified.", where POSIX reports plain ENOENT (2) for both cases. + # Measured on this host against the shipped oracle, not inferred. + # + # Python's `OSError` draws no such distinction on either platform -- it + # says `[Errno 2] No such file or directory` for both -- and that is + # itself part of the divergence this test exists to pin, so the Python + # side stays one literal. Both tails are still pinned exactly; this + # widens the expectation by PLATFORM, never to "exit code only". + rust_tail = ( + "The system cannot find the path specified. (os error 3))." + if os.name == "nt" + else "No such file or directory (os error 2))." + ) + assert r_message == prefix + rust_tail + # `!r`, not `'{...}'`: `OSError.__str__` interpolates the filename with + # `%r`, so on Windows every separator in it comes back DOUBLED + # (`...\\build\\system-manifest.yaml`). Hand-quoting reproduced the POSIX + # rendering only. `!r` is what the runtime itself does, so it is right on + # both platforms and cannot drift from it. + assert p_message == prefix + f"[Errno 2] No such file or directory: {manifest_path!r})." + # Everything OUTSIDE the message -- exit code, `data`, the issue code -- + # is a real match, not just coincidentally unchecked here. + assert {**r_out, "issues": []} == {**p_out, "issues": []} + + +@_ORACLE_REQUIRED +def test_run_no_sdk_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#257 (the introspection set). Exit code and issue CODE match + (``build.plan-unavailable``, 1); the message's wording does not -- the + oracle names three remedies (``--sdk-root``, ``tan sdk switch``, ``tan + bootstrap``) where the port names one (``--sdk-root`` or a sibling + checkout), and neither is a substring of the other. Pinned literally, not + narrowed to the codes alone. + + Everything OUTSIDE the message -- exit code, ``data``, the issue code -- + is a real match too, not just coincidentally unchecked here: mirrors the + whole-envelope-minus-message bar + ``test_size_missing_manifest_is_a_known_divergence_from_the_oracle`` sets + one function above, measured true for ``run`` the same way.""" + home = tmp_path / "home" + argv = ["run", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_code == p_code == 1 + assert [i["code"] for i in r_out["issues"]] == ["build.plan-unavailable"] + assert [i["code"] for i in p_out["issues"]] == ["build.plan-unavailable"] + r_message = r_out["issues"][0]["message"] + p_message = p_out["issues"][0]["message"] + assert r_message == ( + "no alp-sdk checkout found — pass `--sdk-root `, pin one " + "with `tan sdk switch `, set it in settings, or run " + "`tan bootstrap`. The build-plan comes from the SDK's " + "`alp_orchestrate --emit build-plan`." + ) + assert p_message == ( + "no alp-sdk checkout found -- pass `--sdk-root ` or run from a " + "project beside one. Planning reads the SDK's `metadata/**`." + ) + assert r_out["data"] is None + assert p_out["data"] is None + assert {**r_out, "issues": []} == {**p_out, "issues": []} + + +@_ORACLE_REQUIRED +def test_model_bare_invocation_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#253. The oracle's ``model`` is a generic ARGS-forwarding + wrapper (``[ARGS]...`` in its own ``--help``, shared machinery with + ``new-som``/``monitor``/``faultdecode``) that resolves an alp-sdk + checkout before doing anything else and refuses + ``model.failed``/"alp-sdk root is unresolved", exit 2, when none is + found. The port re-implements ``model`` natively with its own ``build`` + subcommand (``Usage: tan model [OPTIONS] [SUBCOMMAND]``) and never + touches an SDK at this step, refusing instead with + ``model.unknown-subcommand``, exit 1, when no subcommand is named. + Neither the exit code nor the issue code agree -- both pinned, not + narrowed to the one thing they share (a ``command: "model"`` JSON + envelope shape).""" + home = tmp_path / "home" + argv = ["model", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_out["command"] == p_out["command"] == "model" + assert (r_code, [i["code"] for i in r_out["issues"]]) == (2, ["model.failed"]) + assert (p_code, [i["code"] for i in p_out["issues"]]) == (1, ["model.unknown-subcommand"]) + + +@_ORACLE_REQUIRED +def test_new_som_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#254. The port's ``new-som`` DOES accept ``--format`` (a hidden, + unread option mirroring clap's ``global = true`` GlobalArgs), but it is + not in ``cli.py``'s ``_HONOURS_ROOT_FORMAT``, so ``--format json`` still + never reaches a ``new-som``-shaped envelope -- ``root`` wraps the run in + its generic ``command: "cli"`` / ``cli.parse-error`` envelope instead, + where the oracle's own ``--format json`` reaches a real + ``command: "new-som"`` refusal (``new-som.failed``, exit 2). The bare, + ``--format``-free invocation now AGREES at exit 2 on both sides: the + port's SDK-root-unresolved preflight moved off the flat exit 1 onto the + forwarder's own ``ValidationFailure``. What still differs there is the + wording alone -- the port adds a ``git clone`` suggestion the oracle + never had.""" + home = tmp_path / "home" + r_code, _ = _run([RUST], ["new-som"], work_dir, home) + p_code, _ = _run(python_command(), ["new-som"], work_dir, home) + assert r_code == 2 + assert p_code == 2 + _, r_json_out = _run([RUST], ["new-som", "--format", "json"], work_dir, home) + _, p_json_out = _run(python_command(), ["new-som", "--format", "json"], work_dir, home) + assert r_json_out["command"] == "new-som" + assert [i["code"] for i in r_json_out["issues"]] == ["new-som.failed"] + assert p_json_out["command"] == "cli" + assert [i["code"] for i in p_json_out["issues"]] == ["cli.parse-error"] + + +@_ORACLE_REQUIRED +def test_monitor_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#255. The oracle's ``monitor`` shares the same SDK-resolving + forwarder as ``model``/``new-som``/``faultdecode`` and refuses + ``monitor.failed``/"alp-sdk root is unresolved", exit 2, with no SDK + resolvable. The port's ``monitor`` is a deliberate redesign + (``monitor_cmd.py``'s own docstring: "no alp-sdk checkout required, + unlike `model`" -- "a deliberate, documented improvement, not a + regression") that never touches an SDK at all; with no ``--port`` given + it refuses at exit 1 with EITHER ``monitor.pyserial-missing`` (pyserial + not installed in THIS interpreter) or ``monitor.no-port`` (pyserial + present, no port named) -- which of the two fires depends on this host's + own package set, so both are accepted here rather than pinning the one + this authoring host happened to hit (tan-cli#313/#324 is exactly the + class of bug that would be). + + This is NOT the same tool-inventory gap `empty_tool_inventory` pins PATH + against for the (now-real, tan-cli#260) `support-bundle` verb: pyserial is + an interpreter PACKAGE, invisible to any PATH pin. The either-or is real + and stays real across this repo's own two CI legs, + named explicitly rather than left as an unexplained widening: + `parity.yml`'s `python-tests` job installs `-e ".[monitor]"` (pyserial + present -> `monitor.no-port`), `ci.yml`'s `python` job installs the bare + package with no extras (`pip install -e ./python`, pyserial absent -> + `monitor.pyserial-missing`) -- both are legitimate, currently-running CI + configurations, not a hypothetical.""" + home = tmp_path / "home" + argv = ["monitor", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert (r_code, [i["code"] for i in r_out["issues"]]) == (2, ["monitor.failed"]) + assert p_code == 1 + p_codes = [i["code"] for i in p_out["issues"]] + assert p_codes in (["monitor.pyserial-missing"], ["monitor.no-port"]), p_codes + + +@_ORACLE_REQUIRED +def test_faultdecode_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#256. Exit codes COINCIDE at 2 here -- which is exactly why + this case exists: pinned on the issue code and ``command`` field too, so + a narrowed "exit code only" comparison could never quietly stand in for + a real match (this file's own stated trap). The oracle forwards to + ``alp faultdecode`` and refuses on the SAME unresolved-SDK guard as + ``model``/``monitor``/``new-som``. The port re-implements + ``faultdecode`` natively (pure ARMv8-M register arithmetic, no SDK read + at all -- see ``faultdecode --help``'s own text) and refuses instead + because no fault register was supplied on the command line.""" + home = tmp_path / "home" + argv = ["faultdecode", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_code == p_code == 2 + assert r_out["command"] == "faultdecode" + assert [i["code"] for i in r_out["issues"]] == ["faultdecode.failed"] + assert p_out["command"] == "cli" + assert [i["code"] for i in p_out["issues"]] == ["cli.parse-error"] + + # --- the harness must be able to go red ------------------------------------ # # A parity run that cannot fail is worse than no parity run: it reads as @@ -623,7 +1071,9 @@ def test_harness_reports_a_planted_exit_code_difference(work_dir, tmp_path): "print('tan v0.5-dev')", # ...and the shape must cover the WHOLE of stdout. A prefix-anchored # match let both of these through as parity, on the one case that - # actually runs today. Rust prints exactly `tan 0.4.1-dev`. + # actually runs today. Rust prints exactly `tan 0.4.1` + # (oracle.PINNED_ORACLE_VERSION owns that spelling); the strings below + # are deliberately fabricated stdout, not either binary's real output. "print('tan 0.5.0-dev'); print('LEAKED EXTRA STDOUT LINE')", "print('tan 9.9.9 THIS IS NOT TAN AT ALL')", ], diff --git a/python/tests/parity/test_run_oracle_parity.py b/python/tests/parity/test_run_oracle_parity.py index 05132160..9aa93b54 100644 --- a/python/tests/parity/test_run_oracle_parity.py +++ b/python/tests/parity/test_run_oracle_parity.py @@ -6,14 +6,16 @@ exists in the REAL `tan run --help` output, so this port can never invent a flag the shipped binary does not have. -**The full-envelope cases are `xfail(strict=True)`, not skipped**, following -`tests/parity/test_oracle_parity.py`'s own precedent for a command not yet -wired end to end: `run` is not yet registered in `tan/cli.py` (a shared -registration point another workflow step owns), so `python -m tan run ...` -404s with "no such command" today. `strict=True` means the day that -registration lands these XPASS and fail the suite -- forcing the one-line -promotion (drop the marker) instead of a landed command silently staying -mis-classified as "not wired" forever. +**The full-envelope cases below are live, pinned known divergences, not +`xfail`.** This file used to carry both as one `xfail(strict=True, +reason="run not yet registered in tan.cli")` block, on the premise that +`python -m tan run ...` still 404d as "no such command" -- stale the moment +`app.command("run")(run)` landed (`tan/cli.py:101`) and never promoted, +caught here only by actually invoking both binaries (tan-cli#257/#258: "do +not skip [running both binaries]... source-reading and doc comments are not +evidence"), not by trusting the comment. `run` genuinely IS registered and +produces a real envelope on both cases; the reason the comparison still +fails is two real, un-narrowed divergences, pinned individually below. """ import re import subprocess @@ -25,13 +27,22 @@ from tan.commands.run_cmd import run as run_fn from . import oracle_fixtures -from .oracle import ENVELOPE, compare, missing_for_live, rust_binary +from .oracle import _run, missing_for_live, python_command, rust_binary RUST = rust_binary() LIVE_GATE = pytest.mark.skipif( missing_for_live(RUST), reason="TAN_PARITY_LIVE=1 needs a Rust tan; set TAN_RUST_BINARY or run `cargo build`", ) +#: The two known-divergence cases below spawn the oracle unconditionally +#: whenever a binary is present (mirrors `test_oracle_parity.py`'s +#: `_ORACLE_REQUIRED`) rather than replaying a frozen fixture under +#: `LIVE_GATE`'s default -- a stale frozen answer is exactly what let the +#: old `xfail` reason above go unnoticed for as long as it did. +_ORACLE_REQUIRED = pytest.mark.skipif( + RUST is None, + reason="needs a built Rust tan; run `cargo build --bin tan` (or set TAN_RUST_BINARY)", +) def _rust_help(*argv: str) -> str: @@ -89,23 +100,89 @@ def test_run_help_is_not_the_same_flag_set_as_build_or_flash(): ) -@LIVE_GATE -@pytest.mark.xfail( - strict=True, - reason="`run` not yet registered in tan.cli -- pending the app.command(\"run\") wiring", -) -@pytest.mark.parametrize( - "case_id, extra", - [ - ("no-sdk-found", []), - ("sdk-root-invalid", ["--sdk-root", "./nowhere"]), - ], - ids=["no-sdk-found", "sdk-root-invalid"], -) -def test_run_matches_the_rust_oracle_on_the_build_failed_path(case_id, extra, tmp_path): +@_ORACLE_REQUIRED +def test_run_no_sdk_is_a_known_divergence_from_the_oracle(tmp_path): + """Exit code, issue code and `data` all agree (`build.plan-unavailable`, + exit 1, `data: null`); only the remedy wording differs. The same case as + `test_run_no_sdk_is_a_known_divergence_from_the_oracle` in + `test_oracle_parity.py`, re-pinned here too because THIS module -- not + that one -- owns `run`'s own flag/wiring surface, and used to carry a + stale `xfail` that hid this exact envelope behind a wrong reason.""" + home = tmp_path / "home" + work = tmp_path / "root" + work.mkdir() + argv = ["run", "--format", "json"] + r_code, r_out = _run([RUST], argv, work, home) + p_code, p_out = _run(python_command(), argv, work, home) + assert r_code == p_code == 1 + assert "sdk" not in r_out and "sdk" not in p_out + assert [i["code"] for i in r_out["issues"]] == ["build.plan-unavailable"] + assert [i["code"] for i in p_out["issues"]] == ["build.plan-unavailable"] + assert r_out["issues"][0]["message"] == ( + "no alp-sdk checkout found — pass `--sdk-root `, pin one " + "with `tan sdk switch `, set it in settings, or run " + "`tan bootstrap`. The build-plan comes from the SDK's " + "`alp_orchestrate --emit build-plan`." + ) + assert p_out["issues"][0]["message"] == ( + "no alp-sdk checkout found -- pass `--sdk-root ` or run from a " + "project beside one. Planning reads the SDK's `metadata/**`." + ) + assert r_out["data"] is None + assert p_out["data"] is None + assert {**r_out, "issues": []} == {**p_out, "issues": []} + + +@_ORACLE_REQUIRED +def test_run_sdk_root_invalid_now_matches_the_oracle(tmp_path): + """Was `test_run_sdk_root_invalid_is_a_known_divergence_from_the_oracle`, + pinning a real defect; the defect is FIXED (tan-cli#257/#258) and this now + pins the parity instead. + + The divergence: the oracle treats an unresolvable explicit `--sdk-root` as + no root at all and refuses with `build.plan-unavailable` / "no alp-sdk + checkout found". The port carried `nowhere` straight through as + `sdk.sourceTier: "sdkRootFlag"` -- `resolve_sdk_root_ladder` is TERMINAL + but UNVALIDATED for the flag tier, matching the oracle's own + `resolve_sdk_tiered` ("terminal for REPORTING") -- and so fell through to + the NEXT missing thing, reporting "no board.yaml found" plus an `sdk` key + the oracle never emits here. It told the customer their project was broken + when the flag they had just typed was what was wrong. + + Fixed in BOTH `build_cmd.build` and `run_cmd.run`: the guard sits at each + flag's own entry point rather than inside the shared ladder, since every + other caller depends on that staying unvalidated -- the same placement + `clean_cmd.sdk_root_resolves` and `flash_cmd._resolve_sdk` already chose. + `run`'s copy mattered on its own account: its resolution line was a + VERBATIM COPY of `build`'s, so fixing only `build` would have left the + twin live under `tan run`. + + The `message` wording still differs -- the oracle names three remedies + where the port names two -- so that one field stays pinned literally on + both sides rather than narrowed away, per this file's own rule against + weakening a comparison to make it pass. Everything else must now AGREE.""" + home = tmp_path / "home" work = tmp_path / "root" work.mkdir() - result = compare( - ["run", "--format", "json", *extra], cwd=work, surface=ENVELOPE, home=tmp_path + argv = ["run", "--format", "json", "--sdk-root", "./nowhere"] + r_code, r_out = _run([RUST], argv, work, home) + p_code, p_out = _run(python_command(), argv, work, home) + assert r_code == p_code == 1 + assert [i["code"] for i in r_out["issues"]] == ["build.plan-unavailable"] + assert [i["code"] for i in p_out["issues"]] == ["build.plan-unavailable"] + # The heart of the fix: an unresolvable explicit root is no root, so + # NEITHER side reports an `sdk` block. The port used to carry + # `{"root": "nowhere", "sourceTier": "sdkRootFlag"}` here. + assert "sdk" not in r_out + assert p_out.get("sdk") is None + assert r_out["issues"][0]["message"] == ( + "no alp-sdk checkout found — pass `--sdk-root `, pin one " + "with `tan sdk switch `, set it in settings, or run " + "`tan bootstrap`. The build-plan comes from the SDK's " + "`alp_orchestrate --emit build-plan`." ) - assert result.matches, f"{case_id}: " + "; ".join(result.diffs) + # Same REFUSAL as the oracle now (no alp-sdk checkout), different wording. + assert "no alp-sdk checkout found" in p_out["issues"][0]["message"] + assert "no board.yaml found" not in p_out["issues"][0]["message"] + assert r_out["data"] is None + assert p_out["data"] is None diff --git a/python/tests/parity/test_support_bundle_oracle_parity.py b/python/tests/parity/test_support_bundle_oracle_parity.py new file mode 100644 index 00000000..a3b8fa76 --- /dev/null +++ b/python/tests/parity/test_support_bundle_oracle_parity.py @@ -0,0 +1,275 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`tan support-bundle` against the Rust oracle, on the same failing host. + +tan-cli#357. The port used to declare its bundled doctor section a deliberate +divergence -- it substituted `doctor_cmd._collect`'s build/flash-readiness +checklist for the oracle's DEBUG-focused report and pinned the exit code at +`SUCCESS`. Measured, that produced: + + Rust: rc=4, ok=false, exitCode=4, issues=[support-bundle.sdkRoot, + support-bundle.hostPrerequisites] (both error) + Python: rc=0, ok=true, exitCode=0, issues=[workspace, westResolved, + hostPython, hostPrerequisites, zephyrSdk, ...] + +so automation read `ok: true` next to error-severity issues, and a bundle +attached to a debug failure carried no debugger state at all. This file is the +LIVE cross-check that closed it. The whole `inspect.context` and the whole +doctor check list are compared element-for-element, with two DECLARED +exceptions -- `GIT_LONGPATHS_WIDENED`/`PORT_ONLY_CHECKS` below -- neither of +which used to be honest about what it actually covered (tan-cli#374 findings +2/5, see both constants' own docstrings). + +**tan-cli#374 finding 2, the gap this file used to have.** The single +failing-host case below (`test_support_bundle_matches_the_oracle_on_a_failing_ +host`) pins its whole `(rc, ok, exitCode)` assertion on a host where +`sdkRoot` already fails on BOTH sides -- so it could not have noticed +`longPaths` ALSO independently flipping the port's exit code, which is +exactly finding 1's bug (`doctor_cmd`'s Windows-only `longPaths` `fail` arm, +tan-cli#306, is not one of the oracle's fail axes at all). Re-running this +file's own comparison with a resolvable `--sdk-root` -- so `sdkRoot` no +longer masks anything -- gave `RUST rc=0 / PY rc=4` before finding 1's fix. +`test_support_bundle_matches_the_oracle_with_a_resolved_sdk` below is that +second, previously-missing case; it is what actually exercises whether the +`GIT_LONGPATHS_WIDENED` exemption two paragraphs down is safe, which the +original single case could not. + +**Its own file, not a case in `test_oracle_parity.py`.** Same convention as +`test_flash_oracle_parity.py`/`test_run_oracle_parity.py`/ +`test_clean_parity.py`/`test_image_size_oracle.py` -- one command's live +comparison per module. + +**Spawns the oracle directly, every run** (like that file's "known divergence" +section), rather than going through `compare()`/`rust_run()`: those replay a +COMMITTED fixture unless `TAN_PARITY_LIVE=1`, and this case's whole value is +that both binaries run on THIS host, whose tool inventory and registry decide +half the verdicts. Skipped only when no oracle binary is built -- `ci.yml`'s +`python` job never runs `cargo build`, `parity.yml`'s `python-tests` job does. +The session-scoped `pinned_oracle` fixture in `conftest.py` still FAILS the run +if the resolved binary is the wrong version. + +**`empty_tool_inventory` is applied to BOTH sides**, which that helper's own +docstring permits precisely here: one identical override applied twice, +symmetrically, is not the one-side-pinned trap tan-cli#313/#324 closed. It is +what makes "this host is missing its prerequisites" true on a developer laptop +that has all of them. +""" +import json +from pathlib import Path + +import pytest + +from .oracle import _run, empty_tool_inventory, python_command, rust_binary + +RUST = rust_binary() + +_ORACLE_REQUIRED = pytest.mark.skipif( + RUST is None, + reason="needs a built Rust tan; run `cargo build --bin tan` (or set TAN_RUST_BINARY)", +) + +#: The one check whose STATUS may legitimately differ, and the issue that owns +#: the difference. tan-cli#306 widened `doctor_cmd.long_paths_check` to read +#: git's own `core.longpaths` beside the registry, because `west update` +#: clones with git and git ignores `LongPathsEnabled` entirely -- so on +#: Windows the port can `warn` (or, before tan-cli#374 finding 1, `fail`) a +#: host the oracle passes. That is a deliberate, already-shipped improvement +#: in `doctor_cmd`, not a `support-bundle` divergence, and it is NOT an +#: exemption from the check-NAME/POSITION comparison this file exists for: +#: `longPaths` must still be PRESENT on both sides, in the same position -- +#: only its STATUS (and the wire issue that follows from it) is excused. +#: +#: **That excusal used to be a real gap, not a cosmetic one** (tan-cli#374 +#: finding 2): before `support_bundle_cmd._demote_long_paths_fail` existed, +#: the excused STATUS was exactly the thing that could drive +#: `doctor_cmd.exit_code_for` -- i.e. the `(rc, ok, exitCode)` triple this +#: file's OTHER assertions treat as load-bearing -- and the one scenario +#: this file ran never surfaced it, because `sdkRoot` was already failing +#: there regardless of what `longPaths` did. Now that the port caps +#: `longPaths` at `warn` before it ever reaches this command's verdict (see +#: that function's own docstring), the excusal is back to being purely +#: cosmetic: on the ONE cell where Rust and the pre-cap port disagreed +#: (registry on, git's own flag not), the WORST the port can report post-cap +#: is `warn`, which -- like every OTHER `longPaths` cell -- can never by +#: itself take the port's exit code somewhere the oracle's isn't. +GIT_LONGPATHS_WIDENED = "longPaths" + +#: Checks the port emits with NO oracle counterpart at all (tan-cli#374 +#: finding 5) -- not a status difference under a shared name, a check the +#: oracle's own bundle never has. Rust folds a rejected +#: `metadata/bootstrap.json` into `hostPrerequisites`'s own `detail` via +#: `manifest_error`; this port reports the same fact as a sibling check +#: instead (`support_bundle_cmd._HOST_CHECK_ORDER`'s own docstring explains +#: why). Declared here, not silently dropped from the comparison: every +#: scenario in this file that resolves an SDK with no real +#: `metadata/bootstrap.json` -- which includes every stub `--sdk-root` this +#: suite uses, since none of them ships a `metadata/` tree -- would otherwise +#: fail the check-NAME-list assertion below for a reason that is not a port +#: bug, or (worse) silently stop comparing names at all if the assertion +#: were loosened the wrong way. +PORT_ONLY_CHECKS = frozenset({"bootstrapManifest"}) + + +@pytest.fixture +def work_dir(tmp_path): + """A scratch cwd nested under its OWN parent -- `discover_workspace_sdk` + probes the cwd's PARENT for a sibling `alp-sdk/`, so running directly in + `tmp_path` would let the `home`/destination directories below decide + whether either binary finds an SDK.""" + work = tmp_path / "root" + work.mkdir() + (work / "board.yaml").write_text("board: {}\n", encoding="utf-8", newline="\n") + return work + + +def _bundle(payload: dict) -> dict: + return json.loads(Path(payload["data"]["outputPath"]).read_text(encoding="utf-8")) + + +def _statuses(bundle: dict) -> list[tuple[str, str]]: + return [(c["name"], c["status"]) for c in bundle["doctor"]["checks"]] + + +def _issue_pairs(payload: dict) -> list[tuple[str, str]]: + return [(i["code"], i["severity"]) for i in payload["issues"]] + + +def _expected_issues(checks: list[tuple[str, str]]) -> list[tuple[str, str]]: + """A check list's own warn/fail rows, translated into the wire issue + shape `_doctor_issues` produces -- shared by both tests below so the two + issue lists agree wherever the checks do, without either test having to + restate them.""" + return [ + (f"support-bundle.{name}", "error" if status == "fail" else "warning") + for name, status in checks + if status in ("warn", "fail") + ] + + +def _assert_doctor_sections_match(r_bundle: dict, p_bundle: dict, r_out: dict, p_out: dict) -> None: + """The doctor section, checks + wire issues, compared with exactly the + two declared exceptions this file owns (`GIT_LONGPATHS_WIDENED`, + `PORT_ONLY_CHECKS` -- see both constants' own docstrings for what each + excuses and why neither is a blanket pass). Shared by every case in this + file so a third one does not have to re-derive this.""" + r_checks = _statuses(r_bundle) + p_checks_raw = _statuses(p_bundle) + # `PORT_ONLY_CHECKS` first: those names have no oracle counterpart to + # line up against at all, so they must not even reach the name-list + # comparison below. + p_checks = [pair for pair in p_checks_raw if pair[0] not in PORT_ONLY_CHECKS] + assert [name for name, _ in p_checks] == [name for name, _ in r_checks] + assert [pair for pair in p_checks if pair[0] != GIT_LONGPATHS_WIDENED] == [ + pair for pair in r_checks if pair[0] != GIT_LONGPATHS_WIDENED + ] + + assert _issue_pairs(r_out) == _expected_issues(r_checks) + # The port's own issues are derived from its RAW (unfiltered) check list + # -- `_doctor_issues` never heard of `PORT_ONLY_CHECKS`, so a + # `bootstrapManifest` warn genuinely rides on the wire and belongs in + # this expectation, unlike in the oracle-shaped comparison above. + assert _issue_pairs(p_out) == _expected_issues(p_checks_raw) + + +@_ORACLE_REQUIRED +def test_support_bundle_matches_the_oracle_on_a_failing_host(work_dir, tmp_path): + """The exact case tan-cli#357 reported: same project, no resolvable SDK, + an empty PATH so the prerequisite probe genuinely finds nothing, separate + bundle destinations.""" + home = tmp_path / "home" + path = empty_tool_inventory(tmp_path) + rust_dest, python_dest = tmp_path / "rust-bundle", tmp_path / "python-bundle" + + r_code, r_out = _run( + [RUST], + ["support-bundle", "--format", "json", "--destination", str(rust_dest)], + work_dir, + home, + env_overrides={"PATH": path}, + ) + p_code, p_out = _run( + python_command(), + ["support-bundle", "--format", "json", "--destination", str(python_dest)], + work_dir, + home, + env_overrides={"PATH": path}, + ) + + # The verdict, and the CLI-wide invariant that goes with it: the process + # exit code, `envelope.exitCode` and `ok` are one fact in three places. + assert (r_code, r_out["ok"], r_out["exitCode"]) == (4, False, 4) + assert (p_code, p_out["ok"], p_out["exitCode"]) == (r_code, r_out["ok"], r_out["exitCode"]) + + r_bundle, p_bundle = _bundle(r_out), _bundle(p_out) + + # The resolved debug context, whole -- `projectSelected` and + # `debuggerExtensions` included, which the port omitted before #357. + # `generatedAt` is the only key that cannot match: two runs, two clocks. + assert {k: v for k, v in p_bundle["inspect"]["context"].items() if k != "generatedAt"} == { + k: v for k, v in r_bundle["inspect"]["context"].items() if k != "generatedAt" + } + + # The doctor section: same checks, same order, same verdicts -- except the + # one check tan-cli#306 deliberately widened (see GIT_LONGPATHS_WIDENED). + _assert_doctor_sections_match(r_bundle, p_bundle, r_out, p_out) + # The two facts the issue report named by hand, pinned literally so a + # future refactor cannot satisfy the structural assertions above with an + # empty check list. + assert ("support-bundle.sdkRoot", "error") in _issue_pairs(p_out) + assert ("support-bundle.hostPrerequisites", "error") in _issue_pairs(p_out) + + +@_ORACLE_REQUIRED +def test_support_bundle_matches_the_oracle_with_a_resolved_sdk(work_dir, tmp_path): + """tan-cli#374 finding 2: the failing-host case above pins its exit-code + assertion on the one scenario where `sdkRoot` already fails on BOTH + sides, which cannot notice `longPaths` ALSO independently flipping the + port's exit code -- exactly finding 1's bug. This case resolves an SDK + instead, so nothing else forces a fail, and the oracle answers `rc=0`. + + Real PATH, not `empty_tool_inventory`: the point of this case is a + HEALTHY host, and an empty PATH would fail `hostPrerequisites` on BOTH + sides, forcing exitCode 4 regardless of whether finding 1's bug is + present -- the exact masking this case exists to remove, just from a + different check. Every `python-tests` CI runner (`ubuntu`/`windows`/ + `macos` `-latest`) ships git/cmake/python/ninja, so this is not + host-dependent in the environment that actually runs it. + + The stub SDK (`scripts/alp_project.py` only, no `metadata/`) is + resolvable but ships no `metadata/bootstrap.json` -- which is exactly + the shape `PORT_ONLY_CHECKS` exists for (tan-cli#374 finding 5): the + port's `bootstrapManifest` warn fires here on every run. + """ + sdk = tmp_path / "sdk" + (sdk / "scripts").mkdir(parents=True) + (sdk / "scripts" / "alp_project.py").write_text("# stub\n", encoding="utf-8", newline="\n") + home = tmp_path / "home" + rust_dest, python_dest = tmp_path / "rust-bundle", tmp_path / "python-bundle" + + def argv(dest: Path) -> list[str]: + return [ + "support-bundle", + "--format", + "json", + "--sdk-root", + str(sdk), + "--destination", + str(dest), + ] + + r_code, r_out = _run([RUST], argv(rust_dest), work_dir, home) + p_code, p_out = _run(python_command(), argv(python_dest), work_dir, home) + + # The verdict, and the CLI-wide invariant that goes with it -- see the + # failing-host case above for why these three are one fact, not three. + # This is the assertion tan-cli#374 finding 1 broke: before its fix, the + # port answered `(4, False, 4)` here while the oracle answered this. + assert (r_code, r_out["ok"], r_out["exitCode"]) == (0, True, 0) + assert (p_code, p_out["ok"], p_out["exitCode"]) == (r_code, r_out["ok"], r_out["exitCode"]) + # The oracle has no fail axis reachable in this scenario at all. + assert r_out["issues"] == [] + + r_bundle, p_bundle = _bundle(r_out), _bundle(p_out) + _assert_doctor_sections_match(r_bundle, p_bundle, r_out, p_out) + # Pinned literally, so a future refactor cannot satisfy the structural + # assertion above by dropping the check `PORT_ONLY_CHECKS` declares. + assert ("support-bundle.bootstrapManifest", "warning") in _issue_pairs(p_out) diff --git a/python/tests/test_stdout_bytes.py b/python/tests/test_stdout_bytes.py new file mode 100644 index 00000000..c5ab20f0 --- /dev/null +++ b/python/tests/test_stdout_bytes.py @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Byte-level pin on the process-boundary stdout fix in `tan.cli.main` +(`_reconfigure_stdio`) and the `ensure_ascii=False` fix in `tan.envelope`. + +Every other test in this suite drives commands through `typer.testing. +CliRunner`, whose `invoke()` captures output into an in-memory +`io.BytesIO`-backed stream that Click itself constructs and never runs +through a platform `TextIOWrapper` -- it applies NO newline translation and +is always UTF-8, regardless of host OS or console code page. That harness +structurally CANNOT see a Windows-console-only defect: a real Windows +`sys.stdout` is a `TextIOWrapper` that translates a written `"\\n"` to +`"\\r\\n"` and encodes with the process's locale code page unless told +otherwise, and CliRunner's fake stream never exercises that path at all. Only +a real subprocess, read back as RAW BYTES (not `text=True`, which would +silently undo the very translation this file exists to catch), can show it. + +Measured before the fix (`tan.cli.main` had no `_reconfigure_stdio`, and +`envelope.py`'s `json.dumps` had no `ensure_ascii=False`): `tan completion +--shell bash` was 3975 bytes with 108 `\\r` where the built oracle +(`target/debug/tan.exe`) was 3867 bytes with zero, and the emitted script was +a hard syntax error when sourced in a strict bash (WSL Ubuntu-22.04: +``syntax error near unexpected token `$'{\\r''``); a non-ASCII `scaffold +--name` value shipped as `\\uXXXX` escapes instead of the oracle's raw UTF-8. +Confirmed to go RED against the pre-fix source (reverting `_reconfigure_ +stdio`'s call site reproduces the 108-`\\r` count and the WSL syntax error +above verbatim). +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +#: `python/` -- `python -m tan` resolves the package off `os.getcwd()`, not +#: this file's own location, so a child process needs it pinned onto +#: `PYTHONPATH` (mirrors `test_cli_skeleton.py`'s `PACKAGE_ROOT`). +PACKAGE_ROOT = Path(__file__).resolve().parent.parent + + +def _run_bytes(*argv: str, cwd: Path | None = None) -> subprocess.CompletedProcess: + """Run `python -m tan ` in a real child process and return RAW + bytes -- deliberately no `text=True`/`encoding=`, which would have + `subprocess` itself perform universal-newline decoding and mask exactly + the `\\r\\n` translation this file must observe on the wire. + """ + env = { + **os.environ, + "PYTHONPATH": os.pathsep.join( + [str(PACKAGE_ROOT), *([p] if (p := os.environ.get("PYTHONPATH")) else [])] + ), + } + return subprocess.run( + [sys.executable, "-m", "tan", *argv], + capture_output=True, + cwd=cwd, + env=env, + ) + + +def test_text_command_stdout_has_no_cr(): + """`tan completion --shell bash`: a plain text-mode command. Pre-fix this + was 3975 bytes with 108 `\\r` (measured); the oracle is 3867 bytes with + zero. A `\\r` in this output is not cosmetic -- WSL's bash refuses to + source the script at all (`syntax error near unexpected token + $'{\\r''`).""" + result = _run_bytes("completion", "--shell", "bash") + assert result.returncode == 0, result.stderr + assert b"\r" not in result.stdout, result.stdout + + +def test_json_envelope_stdout_has_no_cr(): + """`tan clean --format json`: not completion-specific -- ANY `--format + json` envelope ended `\\r\\n` pre-fix, because the defect is at the + process-wide stdout stream, not in any one command.""" + result = _run_bytes("clean", "--format", "json") + assert result.stdout.endswith(b"\n") + assert not result.stdout.endswith(b"\r\n") + assert b"\r" not in result.stdout, result.stdout + + +def test_bare_format_json_stdout_has_no_cr(): + """`tan --format json` alone (a Click-level usage error, routed through + `main`'s own `_usage_error_envelope` fallback) -- the shortest possible + repro that the fix lives at the process boundary, not inside any one + command's own success path.""" + result = _run_bytes("--format", "json") + assert b"\r" not in result.stdout, result.stdout + + +def test_nonascii_value_round_trips_as_raw_utf8_not_escaped(tmp_path): + """`scaffold --name "Sensör Ölçüm" --format json --preview`: pre-fix, + `envelope.py`'s bare `json.dumps` (`ensure_ascii` defaults to `True`) + shipped `"Sens\\u00f6r \\u00d6l\\u00e7\\u00fcm"`; the oracle's + `serde_json::to_string` writes the raw UTF-8 bytes verbatim. `--preview` + so nothing is actually written to `tmp_path`.""" + destination = tmp_path / "sensor-driver" + result = _run_bytes( + "scaffold", + "--name", + "Sensör Ölçüm", + "--template", + "sensor-driver", + "--destination", + str(destination), + "--format", + "json", + "--preview", + ) + assert result.returncode == 0, result.stderr + # Raw UTF-8 for "ö"/"Ö"/"ç"/"ü" on the wire, not a `\uXXXX` escape. + assert "Sensör Ölçüm".encode("utf-8") in result.stdout, result.stdout + assert b"\\u00f6" not in result.stdout, result.stdout + assert b"\\u00d6" not in result.stdout, result.stdout + + +def test_stderr_also_has_no_cr(): + """`_reconfigure_stdio` reconfigures stderr too (the fix's own docstring + names both streams) -- `tan build --bogus --format json` is a Click usage + error that tees its message onto the real stderr live (`_TeeStderr`), + which is exactly the path that would still show `\\r\\n` if only stdout + had been fixed.""" + result = _run_bytes("build", "--bogus", "--format", "json") + assert b"\r" not in result.stderr, result.stderr diff --git a/python/tests/test_verify_binary_script.py b/python/tests/test_verify_binary_script.py new file mode 100644 index 00000000..520332c3 --- /dev/null +++ b/python/tests/test_verify_binary_script.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`scripts/verify_binary.sh` must survive its own documented invocation. + +tan-cli#361: the script's usage line advertises a generic `` +and the surrounding docs use RELATIVE examples (`dist/tan/tan`), but checks +3-5 run after a `cd` into a throwaway project -- so a relative `$BIN` passed +1/5 and 2/5 and then died with `./dist/tan/tan: not found`, and a relative +`$SDK` silently became a nonexistent path under the temp dir when handed to +the binary as `--sdk-root`. Every CI call site passes `$PWD/...`, so no +release build could ever discriminate. + +The binary under test is a ~30-line `sh` stub, not a real freeze: what is +being proved is the SCRIPT's path handling, and a PyInstaller freeze takes +minutes to produce and is absent on a dev box. The stub answers all five +checks and additionally asserts that the `--sdk-root` it receives is the SDK +the caller named -- the half of #361 the issue title does not mention. +""" +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "verify_binary.sh" + +#: Git Bash's `sh` is bash in POSIX mode and would happily accept a bashism, +#: so `dash` is run too wherever it exists (it ships with Git for Windows and +#: is /bin/sh on Debian) -- that is the shell the "POSIX sh, no bashisms" +#: header is actually a claim about. Neither is assumed present. +SHELLS = [s for s in ("sh", "dash") if shutil.which(s)] + +pytestmark = pytest.mark.skipif(not SHELLS, reason="needs a POSIX sh on PATH") + +#: Stands in for the frozen binary. Contains the literal `truststore` because +#: check 5/5 greps $BIN's own bytes for it. `$STUB_SDK_MARKER` is written in +#: by the fixture: the stub refuses a `--sdk-root` that does not contain it, +#: which is what fails if $SDK is left relative across the cd. +STUB = """#!/bin/sh +# Answers the five checks in verify_binary.sh. The name truststore appears +# here so that check 5/5's `grep -q truststore "$BIN"` finds it. +set -eu +case "${1:-}" in +--version) echo "tan 0.0.0-stub" ;; +init) + mkdir -p src + : >board.yaml + echo 'int main(void) { return 0; }' >src/main.c + echo '{"ok":true}' + ;; +generate) + out=; sdk= + while [ $# -gt 0 ]; do + case $1 in + --help) echo " --output PATH where to write the emitted file"; exit 0 ;; + --output) out=$2; shift ;; + --sdk-root) sdk=$2; shift ;; + esac + shift + done + [ -f "$sdk/MARKER" ] || { echo "stub: --sdk-root '$sdk' is not the SDK" >&2; exit 1; } + mkdir -p "$(dirname "$out")" + echo "CONFIG_STUB=y" >"$out" + echo '{"ok":true}' + ;; +*) echo "stub: unexpected argv: $*" >&2; exit 2 ;; +esac +""" + + +@pytest.fixture +def workspace(tmp_path: Path) -> Path: + """`bin/tan` (+ the `_internal/` sibling 5/5 needs) and `sdk/`, both under + one root -- so the same tree can be addressed relatively or absolutely.""" + stub = tmp_path / "bin" / "tan" + stub.parent.mkdir() + stub.write_text(STUB, encoding="utf-8", newline="\n") + stub.chmod(0o755) # Python writes 0o644; sh would refuse to exec it + ca = tmp_path / "bin" / "_internal" / "certifi" / "cacert.pem" + ca.parent.mkdir(parents=True) + ca.write_text("-- stub CA bundle --\n", encoding="utf-8", newline="\n") + (tmp_path / "sdk").mkdir() + (tmp_path / "sdk" / "MARKER").write_text("", encoding="utf-8", newline="\n") + return tmp_path + + +def run(shell: str, workspace: Path, binary: str, sdk: str) -> subprocess.CompletedProcess: + return subprocess.run( + [shell, SCRIPT.as_posix(), binary, sdk], + cwd=workspace, + capture_output=True, + text=True, + # The script writes into cwd, but `mktemp -d` still needs somewhere to + # go, and the ambient env decides which SDK a real tan would resolve. + env={**os.environ, "ALP_SDK_ROOT": ""}, + ) + + +@pytest.mark.parametrize("shell", SHELLS) +@pytest.mark.parametrize( + "binary, sdk", + [ + # THE #361 CASE: fails on the unfixed script at check 3/5 with + # `./bin/tan: not found`, after 1/5 and 2/5 have already gone green. + ("./bin/tan", "./sdk"), + # No `./` prefix -- a bare relative path is the other half of what the + # usage line's `dist/tan/tan` example looks like when pasted. + ("bin/tan", "sdk"), + # The shape CI passes, and the only shape that ever worked. + ("/bin/tan", "/sdk"), + # Mixed, because the two arguments are canonicalized separately. + ("./bin/tan", "/sdk"), + ("/bin/tan", "./sdk"), + ], +) +def test_relative_and_absolute_paths_both_reach_the_end( + workspace: Path, shell: str, binary: str, sdk: str +) -> None: + root = workspace.as_posix() + done = run(shell, workspace, binary.replace("", root), sdk.replace("", root)) + assert done.returncode == 0, f"stdout:\n{done.stdout}\nstderr:\n{done.stderr}" + # Past check 2 is the bar the issue sets; assert the whole run anyway, + # since a stub that satisfies 3-5 costs nothing extra and 4/5 is where a + # relative $SDK -- the half #361's title omits -- would surface. + assert done.stdout.rstrip().splitlines()[-1].startswith("OK: ") + + +@pytest.mark.parametrize("shell", SHELLS) +def test_a_missing_binary_fails_before_anything_runs(workspace: Path, shell: str) -> None: + """Canonicalizing a path that is not there must not turn into a bare `cd` + error from inside a command substitution.""" + done = run(shell, workspace, "./bin/nope", "./sdk") + assert done.returncode == 1 + assert "no such binary: ./bin/nope" in done.stderr + + +@pytest.mark.parametrize("shell", SHELLS) +def test_a_missing_sdk_fails_before_anything_runs(workspace: Path, shell: str) -> None: + done = run(shell, workspace, "./bin/tan", "./nope") + assert done.returncode == 1 + assert "no such alp-sdk checkout: ./nope" in done.stderr diff --git a/scripts/e2e-container.sh b/scripts/e2e-container.sh new file mode 100644 index 00000000..8b8d7914 --- /dev/null +++ b/scripts/e2e-container.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Run the full e2e (scripts/e2e-full.sh) inside a PRISTINE Linux container. +# +# Usage: e2e-container.sh [image] +# +# WHY a container when e2e-full.sh already runs on Linux: a developer's Linux +# box is not a customer's. It has west, cmake, ninja, dtc, gperf, a Zephyr SDK +# and a populated CA store, all installed months ago for other reasons, and +# every one of them silently supplies something `tan` is supposed to provide or +# to complain about. Two release-blocking defects had survived every green +# developer-host run and showed up in the first minute inside a bare +# `ubuntu:24.04`: +# +# * tan-cli#354 -- every HTTPS call died `CERTIFICATE_VERIFY_FAILED`. The +# frozen binary shipped `certifi` and never used it, because `truststore` +# CONSTRUCTS fine on a host with an empty OS trust store and only fails +# later, at verify time, where no `except` around construction can see it. +# Reproduced identically on the published v0.5.0-rc4 asset, so it had +# shipped in every RC. +# * tan-cli#355 -- a clean host was told which tools were missing and not +# that `tan doctor --build --fix` installs them. +# +# The image gets ONLY what the quickstart tells a customer to install: +# ca-certificates, git, python3. Deliberately NO west, NO cmake/ninja/dtc/gperf, +# NO Zephyr SDK -- providing those is `tan bootstrap`'s job, and whether it does +# is the thing under test. (`python3` is here for the HARNESS, which parses +# envelopes with it; `tan` itself is a freeze and needs no system Python. Do not +# read its presence as a tan prerequisite.) +# +# The harness is BIND-MOUNTED from this checkout rather than copied in, so the +# container runs the same file CI and the developer host run -- tan-cli#358 was +# partly a second, drifted copy of it. +set -uo pipefail + +FROZEN="${1:?usage: e2e-container.sh [image]}" +IMAGE="${2:-ubuntu:24.04}" + +HERE=$(cd "$(dirname "$0")" && pwd) +HARNESS="$HERE/e2e-full.sh" +[ -f "$HARNESS" ] || { echo "ABORT: no harness at $HARNESS" >&2; exit 2; } + +# Docker may need sudo, may not. Probe rather than assume: a hardcoded `sudo` +# fails on Docker Desktop and a hardcoded bare `docker` fails on a stock Linux +# install where the user is not in the `docker` group. +DOCKER="${DOCKER:-docker}" +if ! $DOCKER info >/dev/null 2>&1; then + if sudo -n $DOCKER info >/dev/null 2>&1; then + DOCKER="sudo $DOCKER" + else + echo "ABORT: cannot talk to the Docker daemon as this user, and passwordless" >&2 + echo " sudo is unavailable. Start Docker, or add this user to the" >&2 + echo " docker group, or set DOCKER='sudo docker' and re-run." >&2 + exit 2 + fi +fi + +# Accept either shape the freeze is handed around in: the `dist/tan/` directory +# PyInstaller writes, or the `.tar.gz` the release publishes. Both end up at +# /opt/tan/lib/tan inside the container. +if [ -d "$FROZEN" ]; then + MOUNT_SRC=$(cd "$FROZEN" && pwd) + MOUNT_ARGS="-v $MOUNT_SRC:/frozen:ro" + UNPACK='mkdir -p /opt/tan/lib && cp -r /frozen/. /opt/tan/lib/' +elif [ -f "$FROZEN" ]; then + MOUNT_SRC=$(cd "$(dirname "$FROZEN")" && pwd)/$(basename "$FROZEN") + MOUNT_ARGS="-v $MOUNT_SRC:/frozen.tar.gz:ro" + # The published tarball has a single top-level `tan/` directory; strip it so + # the launcher lands at a fixed path either way. + UNPACK='mkdir -p /opt/tan/lib && tar -xzf /frozen.tar.gz -C /opt/tan/lib --strip-components=1' +else + echo "ABORT: $FROZEN is neither a directory nor a file" >&2 + exit 2 +fi + +echo "=== isolated e2e: $IMAGE ===" +echo " frozen: $MOUNT_SRC" +echo " harness: $HARNESS" +echo + +# shellcheck disable=SC2086 # MOUNT_ARGS is deliberately word-split +$DOCKER run --rm \ + $MOUNT_ARGS \ + -v "$HARNESS:/e2e-full.sh:ro" \ + -e "ALP_SDK_REF=${ALP_SDK_REF:-dev}" \ + "$IMAGE" bash -c ' +set -uo pipefail +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq >/dev/null 2>&1 +apt-get install -y -qq --no-install-recommends ca-certificates git python3 >/dev/null 2>&1 + +echo "=== host shape (what a customer actually has) ===" +for t in git python3 west cmake ninja dtc gperf; do + printf " %-8s %s\n" "$t" "$(command -v "$t" 2>/dev/null || echo ABSENT)" +done +echo " CA store: $(ls /etc/ssl/certs/ca-certificates.crt 2>/dev/null || echo ABSENT)" +echo +'"$UNPACK"' +[ -x /opt/tan/lib/tan ] || { echo "ABORT: no launcher at /opt/tan/lib/tan after unpack" >&2; ls -la /opt/tan/lib >&2; exit 2; } +bash /e2e-full.sh /opt/tan/lib/tan /work 2>&1 +' diff --git a/scripts/e2e-full.sh b/scripts/e2e-full.sh new file mode 100644 index 00000000..c09bcef3 --- /dev/null +++ b/scripts/e2e-full.sh @@ -0,0 +1,431 @@ +#!/usr/bin/env bash +# Full cross-platform e2e for tan: fresh host AND dirty host, to a real ARM ELF. +# +# ONE script, run identically on Windows (Git Bash) and Linux (WSL). Anything +# that differs is a finding, not something to paper over with a second script. +# +# Usage: e2e-full.sh +# +# Every regression assertion here was validated against the KNOWN-BAD +# v0.5.0-rc3 asset first and observed to FAIL. A check that has never seen its +# bug is not a check. +set -uo pipefail + +SRC_BIN="${1:?usage: e2e-full.sh }" +WORK="${2:?usage: e2e-full.sh }" + +PASS=0; FAIL=0; FAILED_NAMES="" +ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); FAILED_NAMES="$FAILED_NAMES|$1"; printf ' FAIL %s\n' "$1"; } +note() { printf ' %s\n' "$1"; } +hdr() { printf '\n-- %s --\n' "$1"; } + +# A previous run's tree MUST be gone before this one starts, and a plain +# `rm -rf` is not enough to guarantee that here: a `west update` checkout on +# Windows leaves read-only files, so `rm -rf` reports +# `Permission denied` / `Directory not empty`, exits non-zero, and -- because +# this script is `set -uo pipefail` without `-e` -- the run CONTINUES on a +# half-deleted tree. That happened, and it turned a clean 23/0 into 3/26 whose +# every failure was leftover state rather than a defect. A harness that runs on +# a dirty tree reports fiction, so this aborts instead. +chmod -R +w "$WORK" 2>/dev/null || true +rm -rf "$WORK" 2>/dev/null || true +if [ -e "$WORK" ]; then + echo "ABORT: could not fully remove the previous run's tree at $WORK" >&2 + echo " survivors:" >&2 + find "$WORK" -mindepth 1 2>/dev/null | head -5 | sed 's/^/ /' >&2 + echo " re-run after removing it; continuing would measure stale state." >&2 + exit 2 +fi +mkdir -p "$WORK/home" "$WORK/proj" +export HOME="$WORK/home"; export USERPROFILE="$WORK/home" +unset ALP_SDK_ROOT ZEPHYR_BASE ALP_FLASH_FORCE 2>/dev/null || true +git config --global core.longpaths true 2>/dev/null || true + +# A real `build` needs a toolchain. If a Zephyr SDK exists on this machine, +# bind it -- otherwise `zephyrSdk` legitimately fails, doctor legitimately +# exits 4, and the ARM-ELF leg cannot run at all. Binding it is what a real +# user has; NOT binding it would make the build leg untestable rather than +# rigorous. +# Candidates are DERIVED, never a hardcoded account. This repo is public and +# its history is permanent, so `/home/` in a tracked file is a leak -- +# tests/gates/test_no_leaked_host_paths.py caught exactly that here. Export +# ZEPHYR_SDK_INSTALL_DIR to skip the search entirely. +ZEPHYR_SDK_VERSION="${ZEPHYR_SDK_VERSION:-1.0.1}" +for cand in \ + "${ZEPHYR_SDK_INSTALL_DIR:-}" \ + "$HOME/zephyr-sdk-$ZEPHYR_SDK_VERSION" \ + "$HOME/../zephyr-sdk-$ZEPHYR_SDK_VERSION" \ + "/opt/zephyr-sdk-$ZEPHYR_SDK_VERSION" \ + "/usr/local/zephyr-sdk-$ZEPHYR_SDK_VERSION" \ + "/c/zephyr-sdk-$ZEPHYR_SDK_VERSION" +do + if [ -f "$cand/sdk_version" ]; then export ZEPHYR_SDK_INSTALL_DIR="$cand"; break; fi +done +# `west sdk install` records the location in ~/.cmake/packages/Zephyr-sdk; on +# Windows that is the only place it lands, so a hardcoded path list misses it. +if [ -z "${ZEPHYR_SDK_INSTALL_DIR:-}" ]; then + for reg in "$HOME/.cmake/packages/Zephyr-sdk"/*; do + [ -f "$reg" ] || continue + cand=$(tr -d '\r\n' < "$reg") + [ -f "$cand/sdk_version" ] && { export ZEPHYR_SDK_INSTALL_DIR="$cand"; break; } + done +fi +[ -n "${ZEPHYR_SDK_INSTALL_DIR:-}" ] && echo " sdk: $ZEPHYR_SDK_INSTALL_DIR" || echo " sdk: none found (build leg will be reported, not silently skipped)" + +cd "$WORK/proj" + +# tan must sit BESIDE alp-sdk/ -- the documented quickstart layout, and +# load-bearing for #323 (bootstrap only plans a relocation when the directory +# "holds more than this checkout"; tan itself is what makes it hold more). +# +# tan-cli#349 made this NOT a single-file copy. The freeze is --onedir now: the +# launcher needs its `_internal/` sibling, and copying the executable alone +# gets `[PYI-...:ERROR] Failed to load Python DLL ... LoadLibrary: The +# specified module could not be found.` -- which is exactly what this harness +# did on its first post-#349 run, turning 23 checks red for one reason. +# +# So install it the way install.sh / install.ps1 actually do: the whole tree +# into a lib dir, plus a thin launcher beside alp-sdk/. That keeps the +# quickstart layout the assertions depend on AND exercises the real shipped +# shape rather than a copy no installer ever produces. +_bin_name=$(basename "$SRC_BIN") +_src_dir=$(cd "$(dirname "$SRC_BIN")" && pwd) +if [ -d "$_src_dir/_internal" ]; then + # `cp -r SRC DST` is NOT idempotent: when DST already exists it copies INTO + # it, giving `tan-cli-lib/tan/tan.exe` instead of `tan-cli-lib/tan.exe`. That + # is silent -- the launcher then points at a path that does not exist and + # every command fails with cmd.exe's opaque + # `'"...\tan-cli-lib\tan.exe"' is not recognized`. Copy the CONTENTS into a + # freshly created dir so the layout cannot depend on what was there before. + rm -rf ./tan-cli-lib + mkdir -p ./tan-cli-lib + cp -r "$_src_dir"/. ./tan-cli-lib/ + # Built line by line with `echo`, never a multi-line `printf` format string: + # a CRLF-normalising pass turns escapes embedded in such a format into REAL + # newlines, which is how this block broke once already. + case "$_bin_name" in + *.exe) + # TWO DISTINCT PATHS, and conflating them destroyed the real binary once: + # `_launcher` is what install.ps1 ships for a cmd.exe user, `TAN` is what + # THIS POSIX harness drives. An earlier revision pointed `TAN` into the + # lib dir but left the launcher `echo`s writing to `$TAN`, so it + # overwrote `tan-cli-lib/tan.exe` with a 40-byte `@echo off` script -- + # every call then failed `line 1: @echo: command not found`, which reads + # nothing like "the harness clobbered the binary". + _launcher="$WORK/proj/tan.cmd" + TAN="$WORK/proj/tan-cli-lib/$_bin_name" + # `_bs` holds the separator rather than inlining a backslash: inside + # double quotes bash parses `\\$` as an escaped `$`, so the obvious + # `...tan-cli-lib\\${_bin_name}...` emits a LITERAL `${_bin_name}`. + _q='"'; _bs='\' + echo "@echo off" > "$_launcher" + echo "${_q}%~dp0tan-cli-lib${_bs}${_bin_name}${_q} %*" >> "$_launcher" + # Git Bash cannot exec a `.cmd` by absolute path (exit 127), so the + # harness drives the onedir exe directly. Same shape either way: the exe + # still needs its `_internal/` sibling. + ;; + *) + TAN="$WORK/proj/tan" + echo '#!/bin/sh' > "$TAN" + echo 'exec "$(dirname "$0")/tan-cli-lib/'"${_bin_name}"'" "$@"' >> "$TAN" + chmod +x "$TAN" + ;; + esac + echo " shape: --onedir tree + launcher (tan-cli#349)" + # PROVE the installed binary actually runs before asserting anything about + # tan's behaviour. Every red run in this harness's history -- the missing + # `_internal/`, the nested `tan-cli-lib/tan/`, the clobbered exe -- produced + # a wall of failures whose real cause was that `$TAN` was not a working + # binary. Failing HERE names it in one line instead of 20+ misattributed + # assertion failures. + if ! "$TAN" --version >/dev/null 2>&1; then + echo "ABORT: the installed tan does not run: $TAN" >&2 + echo " size: $(wc -c <"$TAN" 2>/dev/null) bytes" >&2 + echo " error: $("$TAN" --version 2>&1 | head -2)" >&2 + exit 2 + fi +else + # Pre-#349 single-file freeze, and any published asset up to v0.5.0-rc4. + cp "$SRC_BIN" "./$_bin_name" + TAN="$WORK/proj/$_bin_name" + echo " shape: single-file binary" +fi + +echo "=== tan e2e: $(uname -s) $(uname -m) ===" +echo " tan: $TAN" +echo " HOME: $HOME" + +jget() { python3 -c "import json,sys;d=json.load(open(sys.argv[1])); +import functools; +p=sys.argv[2].split('.');v=d +for k in p: + v = (v or {}).get(k) if isinstance(v,dict) else None +print(v if v is not None else 'NONE')" "$1" "$2" 2>/dev/null || echo NONE; } + +# One parseable envelope on stdout, zero bytes on stderr, and -- the part +# tan-cli#358 was missing -- a verdict that is actually CHECKED. +# +# `jrun` used to score a call PASS on "stdout parsed as JSON and stderr was +# empty", never reading RC, `ok` or `exitCode`. So a `flash` that returned +# ok:false / exitCode 1 with `flash.manifest-not-found` printed +# `PASS flash: one envelope, 0-byte stderr (exit 1)` and counted toward +# "23 passed, 0 failed". A harness that cannot fail is not evidence, and this +# one had been reporting a green line for a broken command for two rounds. +# +# Usage: jrun