fix: close the v0.6.0 milestone — absorbs #331, ports the seven missing commands, and takes the validate exit-code break - #352
Conversation
…sal causes (#311, #312) plan_alif_mram_jlink emitted `loadbin <artefact> <app_address>` uncondi- tionally, with no is_raw_bin guard -- unlike its sibling plan_swd_probe, which branches loadbin-for-.bin vs loadfile-for-ELF. A slot0-linked artefact that is not a raw .bin would have had its ELF headers written into on-die MRAM at the load address. It now raises FlashPlanError naming the artefact and pointing at zephyr.bin. Deliberately a refusal rather than plan_swd_probe's loadfile fallback: a loadfile here would silently ignore slot0_load_address, which is the worse failure. Latent today -- no in-tree board.yaml sets slot0_load_address. Separately, the Flow D DPIDR preflight conflated two causes. A banner reporting a DIFFERENT DP ID (a real wrong-board or probe-selection problem) and a banner reporting no DP ID at all (connect failed outright, typically the J-Link still re-enumerating after a prior JLinkExe close) both got: Check the probe selection (flash_args.jlink_serial) and the wiring. The second now says the probe reported no DP ID at all and advises a retry. Detection is conservative and falls back to the existing generic message when it cannot tell -- a widened refusal that claimed the wiring was fine would be worse than the ambiguity. Both cases still refuse to write MRAM; this is a diagnostic split only. Neither backend has a v0.4.1 oracle counterpart -- alif_mram_jlink is new -- so there is no envelope to diff against here.
…at never loaded Zephyr (#308, #309) Two oracle-parity divergences in the executor, where the v0.4.1 oracle does the right thing and the port did not. #308: build_cmd.py passed gap_fillers=() with a literal '# NOT YET PORTED', and execute.py starts from dict(os.environ). ADR-0020 plans never carry ZEPHYR_BASE, so a stale ambient $ZEPHYR_BASE in the user's shell beat the west workspace tan had itself just resolved. The oracle fills it via zephyr_env_overrides; the resolved value already existed on this side as west_workspace_dir. Ported as pure logic into python/tan/core/zephyr_env.py rather than into the command module. EXTRA_ZEPHYR_MODULES joins with ';' on every platform -- it is a CMake list that Zephyr's own zephyr_module.py splits on ';' regardless of host OS, and joining it with ':' on Linux breaks west build configure with 'is not a valid zephyr module'. The values flow through the existing sep_for_key machinery rather than re-implementing the join, and assemble_slice_env's seed-then-append still extends an inherited PYTHONPATH instead of replacing it. Both are covered by tests. #309: a CMakeLists.txt that never calls find_package(Zephyr ...) still configures and links fine under `west build -b <board>` -- CMake only emits a dev warning about the missing project() call -- so a real exit code 0 was not sufficient evidence and a core declared `os: zephyr` could produce a plain host binary and be reported [+] ok. The board name is never even validated, because nothing loaded the code that would validate it. zephyr_boilerplate_loaded / dir_shows_zephyr are ported into build/ manifest.py and applied in execute.py's status assembly, checked only on an otherwise-successful slice and skipped when the slice redirects west's build dir, where the evidence lives somewhere this cannot see. It looks one level below build/ as well, because --sysbuild nests the real per-image Zephyr builds under its own superbuild and only the nested build carries a signal. The guard exposed 13 tests whose plan fixtures declared backend zephyr while dispatching a trivial stand-in tool that produces no Zephyr evidence. Twelve are relabelled to a non-zephyr backend after checking per test that no assertion touched zephyr artefact resolution; the one test genuinely about west's own build-dir resolution instead has its stand-in west script create a real zephyr/ directory, keeping it on the live code path. The separate question of whether `tan init --template minimal-app` should emit a different CMake shape is a maintainer decision and is untouched here; the scaffold is byte-identical to the oracle's.
The Rust gate for this landed in 78d8308 (contract.rs's every_prefixed_issue_code_is_registered, with PREFIXING_SITES), but crates/ ships to nobody -- the release assets are PyInstaller freezes of python/ -- so on the shipping surface there was no source-to-registry scan at all, prefixed or otherwise. The shape the Rust gate exists to catch is already live here: bootstrap_cmd builds f"bootstrap.{code}", debug_config_cmd builds f"debug-config.{code}", doctor_cmd does the same. A literal-only scan sees none of them. The new gate walks emit sites for both literal codes and f-string assembly through a prefixing helper, reading the registry through the same source test_frozen_issue_codes.py already uses rather than inventing a second one. It carries a self-test proving it REJECTS a deliberately unregistered code: a gate that cannot fail is not a gate, which this repo learned for real in tan-cli#275 when a scrubbed env var made one skip silently for eleven commits. Where it must skip, it skips loudly and names what was missing. Run against the tree it found 146 unregistered codes, including cli.command-deferred. All are registered `reserved` in contract/ issue-codes.json -- 146 additions, zero removals, zero modifications. cargo test --locked stays green, frozen_issue_codes included. contract/issue-codes.json ships as a release asset; the next release notes should name these entries.
…nventory (#313, #324) Eight parity cases branch on a which() probe, so their frozen answers record which tools the CAPTURE host had -- and the Python side is then compared live against the REPLAY host. The two sides were not measuring the same environment, so the comparison measured the host rather than the port. #313: three yocto_wic cases froze 'would run dd if=...', because the capture host had dd and no bmaptool. plan_yocto_wic prefers bmaptool when planning only and dd is absent, so on a host with neither the live Python side picked bmaptool and the three failed. Green wherever dd exists, hence green in CI. #324: three west_forward cases froze the 'west not found on PATH' launch error, because the capture host had no west. On a host that HAS west the live Python side genuinely launches it and reports the child's failure instead. Same bug, opposite direction. Neither is a port gap. Run both binaries under a sanitised environment and they agree byte for byte -- same command, same exitCode, same data, same issue code, same message. The port already carries the identical check and the identical string. Replay now pins the Python side's PATH to the recorded inventory. Because that side runs as a subprocess, an in-process monkeypatch cannot reach it, so the pin crosses the process boundary as a scratch PATH. #324 reuses the mechanism #313 introduced rather than adding a second one, generalised to the absent-tool case as oracle.empty_tool_inventory. Under TAN_PARITY_LIVE=1 both sides genuinely spawn, and compare() REFUSES a python_env_overrides rather than silently comparing one pinned side against one unpinned side -- which would be a fresh divergence, not a capture. PROVENANCE.txt now records the capture host's inventory and the amended re-capture recipe, because the recipe at the top of that file hard-errors for these eight by design. Capturing on a host with west installed and the pin dropped would freeze an answer that only replays on hosts that also have west, reintroducing exactly the bug #324 closed.
…in the native-sim e2e The autouse _scrub_sdk_discovery_env fixture isolated ALP_SDK_ROOT, SOURCE_DATE_EPOCH, HOME and USERPROFILE but left ZEPHYR_BASE inherited from the developer's shell, so one more discovery input could differ between a local run and CI. It is scrubbed now. test_native_sim_e2e's _zephyr_base() read os.environ directly, which would have defeated that scrub; it reads REAL_ENVIRON instead, matching the idiom the same file already uses for the real west build subprocess env. Verified with and without a hostile ZEPHYR_BASE exported: byte-identical counts either way across the doctor, kconfig and native-sim suites. This is NOT a fix for #297, which stays open. That issue's diagnosis -- that an ambient ZEPHYR_BASE makes test_west_resolved_reproduces_and_closes_tan_ cli_123 flaky -- does not survive measurement: find_workspace_venv's upward .venv walk resolves before the ZEPHYR_BASE read is ever reached, and the test plants its .venv at the subprocess's own cwd. With a hostile value set, that test gives 5 passed / 128 deselected, identical to the unset baseline. The original Windows-side failure remains unreproduced and needs a fresh repro on Windows.
…ref (#213) client_payload.sdk_ref was read into a step output and echoed via ::notice::, which is job-log-only -- it never reached the Actions API display_title field alp-sdk polls to correlate the run it triggered. A top-level run-name key surfaces it. The non-dispatch arm is an empty string, not a formatted event name: GitHub falls back to the event-specific default title when run-name is empty or whitespace, so push and pull_request runs keep showing their commit message or PR title. Naming the event there instead would have overwritten that default on every such run -- a regression traded for the fix. Same idiom this file already uses one line away for ref:. Inert until it reaches the default branch.
…numbers (#224) The gate landed keyed on (file, ABSOLUTE LINE), which reds the build on any unrelated edit that shifts a declared site. It happened immediately: dev's fc88ca1 added +69/-18 lines to bootstrap_cmd.py, moving f"bootstrap.{code}" from :1522 to :1532, and both the `python` and `seam1 -- plan-shape parity` CI jobs went red on the PR's merge commit while the branch itself was green. Re-pinning the number was the wrong fix. A gate that must be hand-re-pinned after every unrelated edit trains reviewers to update the pin mechanically without reading what changed, which is how a gate stops gating -- the tan-cli#275 lesson this file already cites. Keys are now (file, enclosing qualname). Line numbers stay in the error messages, where a human needs them, and appear in no hand-maintained key. Proved by measurement: shift bootstrap_cmd.py, gate still passes; plant an unregistered code, gate still fails naming it. Re-keying opened a smaller hole, found by review and reproduced: a SECOND template inside an already-declared function collapses onto one key, leaving only the scalar EXPECTED_TEMPLATE_COUNT -- whose own failure message says to bump the count. So each declaration now pins how many sites it covers, and a mismatch fails naming the qualname, both counts and every matched file:line. _FORWARDER_SUFFIXES, the third bucket with the same many-to-one shape, gets the same discipline. _check_site_counts was a closure and could not be tested. This file states its own bar -- an assertion nobody has ever watched fail is not proven to fire -- and met it for its two older assertions. It is extracted and tested now, including the _ACKNOWLEDGED_CEILINGS half that is empty in production and had never had a real value driven through it. One hole is documented rather than closed. Resolving an f-string statically cannot tell a substituted name bound by the enclosing function's parameter list from one rebound by a comprehension target of the same spelling, so a 1-for-1 swap at a declared key needs no count bump at all and stays green. That is a limit, not a missing pin; chasing it means shadowing heuristics that false-positive on ordinary Python. The module docstring records it as an acknowledged ceiling with the verbatim repro and names the real backstop: contract/issue-codes.json is read directly by release.yml when building the published envelope-contract.json, and alp-sdk-vscode fails open on a code it does not recognise, so the cost is a silently-ignored issue, not a crash. Also drops three line-number comments that had themselves rotted (2072, 2061, 2326 -> 2099, 2088, 2363) rather than re-measuring them, in the file whose whole subject is that line identity decays.
…nstead of asserting it Milestone v0.6.0's goal is, 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." Not one of those verbs appeared in this file's CASES, which covered --version, a bogus command, a bare invocation, debug-config, presets, clean and build --plan. The milestone's central claim had no instrument that could read it. Nine case-functions (17 node IDs) now cover every named verb, each established by RUNNING target/debug/tan rather than by reading crates/. Matching, as live parity cases: explain (overview, unknown-template, unknown-target), image, renode -- full envelopes byte-identical. Diverging, each as a documented known-divergence case that pins the divergence on BOTH sides so it fails if silently fixed or worsened: model, new-som, monitor, faultdecode, size, run, and all seven deferred verbs. Several are structural, not cosmetic. faultdecode's exit codes coincide at 2 while the envelopes differ entirely -- the port re-implemented it as ARMv8-M register arithmetic that reads no SDK, so it answers cli.parse-error where the oracle answers faultdecode.failed. new-som's Click command declares no --format option at all, so --format json is a usage error rather than an envelope. size's divergence is permanent: two runtimes rendering the same ENOENT, Rust's `(No such file or directory (os error 2))` against Python's `([Errno 2] No such file or directory: '<path>')`. Two harness defects had to be closed for any of this to mean anything. The support-bundle case first encoded this host's tool inventory, gaining a third issue under a stripped PATH -- tan-cli#313 and tan-cli#324 reintroduced in the PR that fixed them. These cases spawn both binaries live every run, so unlike the frozen-replay cases they can pin PATH SYMMETRICALLY, and now do. empty_tool_inventory also seeds a real `which`: with a literally empty PATH the oracle's probe cannot resolve `which` itself and reports every tool missing because it could not look, which is an artefact, not an answer. It raises now rather than silently returning to that. rust_binary() preferred target/release over target/debug. On a tree with a stale release build that silently measured against the wrong oracle -- forced over the other four parity files it gave "108 failed, 8 passed", red but reading as port bugs, with eight cases passing against the wrong binary. It picks the most recently built profile now, refuses an mtime tie rather than breaking it silently, and a session-scoped fixture in the new conftest.py asserts the resolved binary's --version for every file under tests/parity. Wrongness fails loudly; absence stays a skip, which is each file's own gate to decide (missing_for_live's rule, tan-cli#272). Resolution happens in the fixture body, not at conftest import, so a raise cannot abort collection for the whole suite.
…bootstrap warn count Merging dev put the tan-cli#224 emit-site gate in one tree with two commits that landed while this branch was in flight, and it caught both. fcb2153 (tan-cli#325, `fix(init,generate): confine writes to the project after symlink resolution`) added `generate.write-escapes-project` -- raised as a GenerateError with ExitCode.WRITE_FAILURE when resolve_confined finds a target's output path resolving outside the project root -- and registered it nowhere. That 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. Registered `reserved`/`consumer: none`, which costs nothing since a reserved code may still be renamed freely. It is unregistered on dev right now, not only here -- this registration fixes it for everyone at merge. 518ac8c (tan-cli#334) split the single `zephyr-base-incompatible` warn into a found/else pair so the message can name the evidence, taking that helper from 16 call sites to 17. One code, two sites, already registered. Checked before bumping, which is the entire point of pinning the count: it forced the look rather than allowing a reflexive increment.
…f cancelling it Rebasing #331 onto dev put two independent fixes to the same block of execute_slices side by side, and git merged them cleanly -- textually. The composition was wrong in two ways, one of them silent. #308 FILLS ZEPHYR_BASE from the workspace tan resolved, as a gap filler merged through assemble_slice_env. #336 POPS an inherited ZEPHYR_BASE off a west slice's env, and runs after that merge. Its condition tested only `sl.env` -- the plan's own pins -- so it could not see the gap filler's contribution and stripped it right back out, on precisely the slices #308 exists to serve (the ones that do NOT pin the key themselves). The pop now tests the assembled `slice_env`, which holds the plan's env AND the gap fillers, so "present" means "something authoritative decided this" and only an ambient, inherited value is dropped. The two then compose in 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 never `west update`d). That defect was invisible to CI. Every test in test_execute_zephyr_env.py drives a `backend: baremetal` slice whose tool is the interpreter itself, so `is_west` is False and the pop is never reached. Three tests are added that use `tool: "west"` -- the only shape that reaches it -- covering the gap-fill surviving the pop, the pop still firing where #308 has nothing to give, and a plan-pinned value surviving both. The first fails on the naive composition and passes here; verified by reverting the condition and re-running. The second defect was CI-visible. #309's Zephyr-boilerplate guard sets both `status` and `message`, but #336's re-wording block sat AFTER it and recomputed `message` unconditionally -- so a guard-failed slice reported `failed` with `message: None`, a verdict with its evidence deleted. #336's re-wording moves into the initial message assembly, leaving #309's guard as the last word. Confirmed against the broken shape: it reds test_the_refusal_message_matches_the_oracle_verbatim. Also fixes five #308 unit tests that hardcoded POSIX literals against values the code renders with str(Path(...)): on Windows `Path("/sdk")` is `\sdk`, so all five failed on the required `test (windows-latest)` gate. Production is correct -- it feeds real resolved paths -- so the expectations now derive through the same str(Path(...)) and mean what they say on either platform. And #336's fake west shim now writes the build/CMakeCache.txt ZEPHYR_BASE: entry a real successful `west build` leaves behind, honouring #307's injected `-d <slice dir>/build` rather than assuming <cwd>/build (the child's cwd is the workspace, where the shim itself lives). Without it the shim exits 0 having produced nothing and #309 correctly fails the slice, masking what #336's assertions are about. #318's crates/ change is dropped from this branch: dev already carries the equivalent from #333, in a stronger form that keeps the byte-identity marker assertion alongside the wait_until_spawnable guard. crates/ is unchanged against dev. Suite: 1814 passed, 194 skipped, 6 xfailed, 0 failed.
…nsent hole Wave 1 of the v0.6.0 batch: scaffold, completion, diff, pinmux, inspect, trace and support-bundle are real commands rather than deferral stubs (tan-cli#260, #257), new-som/monitor/faultdecode reach oracle parity (#254, #255, #256), renode gains --sim-mode (#77), doctor gains --fix and names a dangling global default (#91, #344), debug-config restores its preLaunchTask defaults and flags the unresolvable gdbserver address (#138, #321), and validate takes the exit 1 -> 2 break (#262). Every unit was measured against the running oracle binary, not read out of crates/, and every one was then adversarially re-verified by a second agent whose job was to refute it. That second pass is what this commit is mostly about; two of its findings were blockers. tan-cli#91 shipped a consent gate with three of its five conditions. The hand-written form tested only the flags (!non_interactive && !ci && !is_json) 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 under fully captured pipes, where `tan doctor --fix` spawned four real winget installs (Git.Git, Kitware.CMake, Python.Python.3.12, Ninja-build.Ninja) with nobody watching. A redirected stdio stream is the most common shape of an automated run, so the omitted half was the one that mattered. The whole doctor --fix suite stayed green through three independent mutations of that guard, including deleting it outright. Fixed at the root rather than at the call site: tan/core/consent.py is now the one implementation of GlobalArgs::can_prompt(), imported by doctor_cmd and scaffold_cmd. tests/core/test_consent.py is exhaustive over all 32 flag/tty combinations -- a sampled test is exactly what let this through, since no case in the old suite combined "flags all clear" with "stdio is not a terminal". Verified the tests catch it: removing the two isatty() lines reds 6 of them. End to end, `tan doctor --fix` under captured pipes now spawns nothing. tan-cli#138's "restore the six v0.3.1 defaults" is implemented as THREE, not six-minus-servers. Restoring the yocto-userspace label would re-break what alp-sdk-vscode#406 deliberately fixed: its preLaunchTaskFor maps only the three build kinds, because the sole task registered for yocto-userspace is a placeholder that exits 1 by design, and naming it would put VS Code's "preLaunchTask terminated with exit code 1 -- Debug Anyway / Show Errors" dialog in front of every F5, including the hand-configured setup that works. So #138 and #321 pull opposite ways and only the three build labels are safe; the reasoning is recorded at DEFAULT_PRE_LAUNCH_TASK with the consumer's own wording quoted. deferred_cmd.py keeps only the constants build_cmd.py needs for its deferred FLAGS; the stub factory and DEFERRED_VERBS are gone, and cli.py spells the seven names into _HONOURS_ROOT_FORMAT instead of splatting a tuple that no longer exists. Known-red and tracked, not claimed done: the shared-file debts every unit was barred from touching (contract/issue-codes.json entries, the issue-code gate's helper tables, test_oracle_parity.py's _DEFERRED_VERBS rows, five debug-config conformance fixtures), plus the oracle divergences the verifiers found in diff/pinmux and support-bundle, and a repo-wide CRLF/stdout divergence. All are itemised on the PR and fixed in the next pass.
Wave 1 landed (
|
… on Windows
Wave 2. Every defect below was found by RUNNING both binaries side by side --
none by reading crates/ -- and each is fixed against the measurement.
Oracle divergences in the newly ported commands:
* diff false-REFUSED a board.yaml the oracle accepts. PyYAML's SafeLoader is
YAML 1.1 and resolves on/off/yes/no/y/n to bool; serde_yaml is YAML 1.2 and
resolves them to strings. So `os: on` produced
`diff.schema-violation: os: expected a string, got a boolean` at exit 2 where
the oracle exits 0 -- and it was every str-typed field, not just `os`. A
YAML-1.2 bool loader fixes the class.
* diff false-ACCEPTED and fabricated a change entry the oracle never emits:
`iot: {wifi: "yes"}` gave exit 0 with an `iot` removal, where the oracle
refuses at exit 2. iot's four toggles and inference's fields are type-checked
before pruning now.
* diff dropped the whole `sdk` envelope block whenever --sdk-root was passed,
on both the success and failure paths. Two docstrings asserted the opposite.
* pinmux hard-refused a non-string scalar pad field at exit 2; the oracle
coerces it and exits 0. Now only list/dict refuse.
* support-bundle echoed an invalid --target-kind/--server back into
data.targetKind/data.server; the oracle reports the DEFAULTS, never
partial-parses. It also flipped the exit code 0 -> 4 on a bundle that was
written successfully -- the doctor section is DATA inside the bundle, not
this command's verdict.
* monitor rejected every global flag v0.4.1 accepts (--sdk-root, --project,
--board-yaml, --quiet, --verbose, --no-color: `No such option` at exit 2),
breaking tan-cli#255's own acceptance line. README also still listed monitor
as an alp_cli forwarder, which is false for this port.
A process-wide output defect nobody had a test that could see:
* Every command's stdout was CRLF on Windows where the oracle emits LF, and
non-ASCII was \u-escaped where the oracle emits raw UTF-8. The emitted
`tan completion --shell bash` script was consequently a HARD SYNTAX ERROR
when sourced in a strict bash (`syntax error near unexpected token $'{\r'`).
Fixed once at the process boundary in main() rather than per-command. Every
existing test asserts through CliRunner, whose in-memory stream applies no
newline translation, so the suite structurally could not see this class --
tests/test_stdout_bytes.py drives a real subprocess and asserts raw bytes.
Three suite failures, each a defect in the EXPECTATION rather than the code:
* test_build_streaming built its expectation with json.dumps at the default
ensure_ascii=True, so it measured json.dumps' default rather than what tan
wrote, and reddened on a message carrying an em dash.
* test_size_missing_manifest pinned POSIX-only literals and was a Linux-only
pass. The missing component is the `build` DIRECTORY, and Windows
distinguishes that from a missing leaf: ERROR_PATH_NOT_FOUND (3), "The system
cannot find the path specified.", against POSIX ENOENT (2) for both. It also
rebuilt the path as str(Path), all-backslash, which NEITHER binary emits --
both keep the root verbatim and join the tail with os.sep -- and hand-quoted
the Python filename where OSError interpolates it with %r, doubling every
separator. Widened by PLATFORM, never to "exit code only".
* renode_sim.py's WIRED_CONSOLE_SKUS hardcodes E1M-AEN801, a real vendor fact
the hardware-fact gate is right to flag. Allowlisted as DEBT with its
retirement condition, matching how scaffold.py and models.py are already
recorded -- not 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.
Also: doctor --fix now reports spawn errors, non-zero exits and timeouts
instead of `continue`-ing silently, and its wiring has tests that fail against
each of the three mutations that previously left the suite green. The shared
registries the per-unit agents could not reach are reconciled -- issue codes
registered, gate tables extended without weakening the gate, the seven stale
_DEFERRED_VERBS rows removed, five debug-config conformance fixtures
regenerated for the three-of-four preLaunchTask decision.
Suite: 2091 passed, 177 skipped, 6 xfailed, 0 failed.
…acting 14 MB per run Waves 3 and 4 of the v0.6.0 batch. tan-cli#261 -- global flags were registered inconsistently across the command surface. Measured by INVOKING both binaries rather than reading --help (hidden options do not appear there): 99 sites across 17 commands where the port answered "No such option" for a flag the v0.4.1 oracle accepts. The issue asked for a shared mechanism rather than 18 copy-pasted fixes, "so a global flag is declared once and applies everywhere" -- tan/core/global_flags.py is that, and tests/gates/test_global_flags_gate.py is the deliverable that keeps it true, so the next command added cannot get it wrong again. After: 0 sites. A traceback-aware smoke pass (not merely "was it rejected") caught a real regression on the way -- PEP 563 stringized annotations on the wrapped functions raising `RuntimeError: Type not yet supported: str`. tan-cli#349 -- the frozen binary re-extracted ~14 MB of runtime into a fresh temp dir on EVERY invocation. Reported at 13.25/19.35/19.35/18.58/19.74 s on macOS arm64 against `git --version` at 0.01 s; I reproduced the same shape on the published rc4 assets at 1.05-1.19 s on Windows and 0.36-0.51 s on Linux, so this was every platform, with macOS a further ~40x on top because extracted .dylibs are not covered by the parent's ad-hoc signature and get verified individually on load. This was not a comfort problem. alp-sdk-vscode caps its version probe at `timeout: 3000` (vscodeAdapter.ts:1406) and `commandOnPath` at 5 s -- so on macOS the extension's probe already timed out. Switched to --onedir, which extracts once at install time. Proven before the pipeline was rewritten, same commit, same host: --onefile 0.880 s mean, --onedir 0.337 s mean. The stable-tmpdir alternative was rejected deliberately -- it caches extracted executable code in a fixed predictable path, which is not something to ship to customers -- and signing alone would fix macOS only while leaving the re-extraction intact. Each target now ships one archive (.zip on Windows, .tar.gz elsewhere), and everything that assumed one raw file per target moved in the same slice: release.yml, clean-host.yml, install.sh, install.ps1, getting-started.yml, python-binaries.yml, verify_binary.sh and docs/release-contract.md. install.ps1 mattered most -- it still named `tan-x86_64-pc-windows-msvc.exe`, which release.yml no longer publishes, so every Windows user running the documented installer would have taken a 404. verify_binary.sh's CA-trust check needed rewriting rather than re-pointing: under --onedir the certifi bundle lives in _internal/ instead of inside the executable, so `grep cacert.pem "$BIN"` returns nothing even on a healthy freeze. That check exists because tan-cli#304 shipped an asset with NO CA bundle and TLS died everywhere, so it was proven both ways -- passes on an intact onedir build, and fails with `no certifi CA bundle at .../_internal/ certifi/cacert.pem` once the bundle is moved aside. The regression gate is the point: tests/conformance/test_packaged_binary.py now asserts `--version` stays inside a 0.6 s budget. Validated against the known-bad artifact, not inferred -- it passes at 0.297 s on the onedir build and fails at 0.94 s against a real --onefile build of the same commit. The e2e harness asserts correctness only, which is exactly how a 19 s startup shipped unnoticed. Also: tan-cli#257's run-parity file carried a stale xfail(strict=True) claiming `run` was "not yet registered in tan.cli", which had gone stale unnoticed and was hiding two real divergences behind a wrong excuse; tan-cli#259's three environment-dependent tests are pinned (one read the real terminal width via COLUMNS -- `COLUMNS=20 pytest` reproduced the failure before the fix). Suite: 2428 passed, 170 skipped, 9 xfailed, 0 failed.
…, and run stops accepting a bogus --sdk-root tan-cli#350 (release-blocker) -- `tan validate` on a project with no board.yaml said "validate: validation failure". Nothing had been validated: no board.yaml was found, which is the state every user is in before `tan init`, and the message sent them looking for a defect in a file that does not exist. Its siblings all name the missing thing and the fix -- `build` names --sdk-root, `size` names `tan build --project .`, `bootstrap` names git clone plus --sdk-root -- and validate was the odd one out. Text mode now says "validate: no board.yaml to validate", and the shared message names WHERE tan looked and the remedy (`tan init` / `--board-yaml <path>`). The distinct `validate.board-yaml-missing` code is unchanged, so a machine consumer that already told the two cases apart still does. This is a deliberate divergence, not a port fix: the v0.4.1 oracle prints the same two lines at the same exit 2, so it is commented as such at the code. Reported as "that is the whole message" -- worth recording that the message was already two lines and the second one already carried the path, on rc3 and on the oracle both. The reported wording was not what the binary does; the substance was right anyway, and it is the verdict, not the length, that was wrong. tan-cli#351 -- `tan sdk list` refused without network and exited 1 while `sdk current` answered the same absence at exit 0. Measured the oracle first: it has NO --online flag at all and reaches GitHub unconditionally, so there is no "offline list" behaviour to match and repurposing bare `list` to answer a local question would invent a second meaning for one verb. Instead the network requirement stops being reported as a failure. tan build / tan run --sdk-root <nonexistent> -- the port RESOLVED the bogus path, reported `sdk.sourceTier: "sdkRootFlag"`, and was then refused for the next missing thing: "no board.yaml found", plus an `sdk` key the oracle never emits there. It told the customer their project was broken when the flag they had just typed was what was wrong. The oracle treats an unresolvable explicit root as no root at all and refuses with "no alp-sdk checkout found". This is the class tan-cli#258 ranks first -- "a flag that silently changed meaning is worse than one that disappeared, because the customer's script keeps running" -- and `build` is in the essential set that had to be gap-free. Guarded at each flag's own entry point rather than inside `resolve_sdk_root_ladder`, because every other caller depends on that staying unvalidated (I-31 terminal-for-REPORTING, matching the oracle's own `resolve_sdk_tiered`); the same placement `clean_cmd.sdk_root_resolves` and `flash_cmd._resolve_sdk` already chose. `run` needed its own copy: its resolution line was a VERBATIM COPY of `build`'s, so fixing only `build` would have left the twin live under `tan run`. test_run_sdk_root_invalid_is_a_known_divergence_from_the_oracle went RED on the fix, which is what a pinned-divergence test is for -- it is now test_run_sdk_root_invalid_now_matches_the_oracle and asserts neither side emits an `sdk` block. The message wording still differs (the oracle names three remedies where the port names two) and stays pinned literally on both sides rather than narrowed away. Suite: 2428 passed, 170 skipped, 9 xfailed, 0 failed.
…obe selection (#353) Found by running tan-cli#268's Target 1 on the real board -- e1m-aen-evk-01, E8 AE822, J-Link SN 603000869, DPS-150 Vin 16.0 V -- entirely through tan. `tan init` and `tan build` passed; the flash could not complete without four values nothing in the chain emits. Filed as #353. Two of the four are tan's and are fixed here; the other two need a product decision and are not patched over. FIXED, tan-side: alp-sdk's manifest reports `output_artefact: .../zephyr.elf` for an AEN801 slot0 slice while the raw `.../zephyr.bin` the mramxip shape needs sits in the same directory. tan-cli#311's guard refused -- correctly, since loadbin'ing an ELF 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 sibling `.bin` is now resolved when it really exists, and the RESOLVED path is what reaches loadbin/verifybin, not merely what the guard inspected. This is a resolution, not a relaxation: it only ever swaps in a file that is a real raw `.bin`, is the artefact's own sibling (same directory, same stem), and exists on disk. An ELF with no sibling `.bin` still hits the refusal, which now also says no sibling was found. Proven both ways -- the three new tests fail with the resolution removed and pass with it. The connect-failure remediation now names the unpinned case. tan emits no `SelectEmuBySN` when `flash_args.jlink_serial` is absent, which is fine on a single-probe host and cannot connect at all on a bench carrying several: the AEN bench has three J-Links, and SEGGER's answer is the bare "Connecting to J-Link ...FAILED: Cannot connect to the probe/programmer." Measured -- the flash only succeeded once jlink_serial was pinned by hand. The plan is deliberately unchanged; refusing would break every correct single-probe host. NOT FIXED, and not papered over (see #353): Nothing runs SETOOLS `app-gen-toc`, so `flash_args.atoc`/`atoc_address` are never produced -- I signed by hand to get past it. Whether tan runs the signing step or alp-sdk emits a signed ATOC during build is a product decision, and SETOOLS is license-gated, which is exactly why it needs one. `slot0_load_address` is likewise absent and is a build-time property (only correct under CONFIG_USE_DT_CODE_PARTITION=y) that tan cannot detect. Also here: the e2e harness is vendored at scripts/e2e-full.sh instead of living in a scratch directory, which is how it drifted unreviewed and how a check survived that reported PASS on `UNREADABLE == UNREADABLE` -- two runs equally broken comparing equal. It now installs the --onedir tree the way install.sh does, aborts instead of running on a tree a previous run left behind (read-only Zephyr files defeat a plain `rm -rf`, and the run continued on stale state), and drives the onedir exe directly, since Git Bash cannot exec a `.cmd` by absolute path and returned 127 for every call. Bench facts worth recording: slot0 byte-matched the flashed .bin on all four words (20004250 80015A51 8001F9F3 80015A3D) and SURVIVED a true cold power-cycle, so Flow D does persist -- the skill's "verified but did not commit" note is stale. The probe was identity-checked before every write (DPIDR 0x4C013477, the AEN E8; 0x0BE12477 would have been the GD32 bridge on another board, which shares the same cloned serial). Suite: 2431 passed, 170 skipped, 9 xfailed, 0 failed.
…iction 23/23 on Windows and 23/23 on Linux against --onedir freezes of this branch. Getting there took five rounds, and EVERY failure was the harness rather than tan -- which is the reason this now lives in the repo instead of a scratch directory where it drifted unreviewed. The six defects, each of which produced a confidently wrong signal: 1. A FALSE PASS. `#336` compared slice outcomes for equality, and after the onedir change both sides produced UNREADABLE and compared equal -- so it printed PASS while nothing had built at all. That check exists to catch a bug that silently drops one core from a multi-core build. It now fails explicitly when either side is UNREADABLE: two runs being equally broken is not the property under test. 2. CRLF. Python's `write_text` emits \r\n on Windows, so a harness edited that way and piped into WSL died at `set: pipefail: invalid option name` before running a single check. 3. MSYS path translation. `wsl -- bash /tmp/probe.sh` became `bash C:/Users/.../Temp/probe.sh`. Needs MSYS_NO_PATHCONV=1 ON THE CALL; exporting it does nothing. 4. A non-idempotent copy. `cp -r SRC DST` copies INTO an existing DST, giving `tan-cli-lib/tan/tan.exe`, so the launcher pointed at a path that did not exist. 5. A silent `rm -rf`. A `west update` checkout leaves read-only files on Windows, so the cleanup failed, the run continued on a half-deleted tree, and a clean 23/0 became 3/26 whose every failure was stale state. It now ABORTS rather than measuring a dirty tree. 6. The harness CLOBBERED THE BINARY. Pointing TAN at the onedir exe while leaving the launcher `echo`s writing to $TAN overwrote the real 15 MB executable with a 40-byte `@echo off` script; every call then failed `line 1: @echo: command not found`. Launcher and driven binary are now two distinct paths. The guard that makes this class un-misattributable: the harness now runs `$TAN --version` before asserting anything, and aborts naming the binary and its size. Defects 1, 4, 5 and 6 all had the same underlying fault -- $TAN was not a working binary -- and all four surfaced as 20+ misattributed assertion failures instead of one line. scripts/e2e-linux-freeze.sh is a FILE, not an inline `wsl -- bash -c` block, and its header says why: Git Bash expands $PWD/$PATH in the OUTER shell before wsl sees the string -- inside single quotes, and even with MSYS_NO_PATHCONV=1 -- so they arrive EMPTY. Proven by an inline block echoing `P=`. The symptom was `build_binary.sh: line 126: python: command not found`, which reads like a broken WSL while .venv-build/bin/python existed, resolved to /usr/bin/python3.12, and had PyInstaller 6.21.0. That cost three rounds. Nothing in tan/ changed here. The product findings this harness DID surface stand: the #308/#309 composition cancel, the repo-wide CRLF/UTF-8 stdout divergence, monitor's rejected globals, and #353's AEN801 flash gap.
Everything in the v0.6.0 milestone is closed hereSixteen commits. Suite 2431 passed, 170 skipped, 9 xfailed, 0 failed.
Only #253 ( Product decisions taken, so review is not archaeology
The two blockers the verify pass caught before they shipped
#308 was silently cancelled by #336. Git merged them cleanly, textually. A guard that fired on real silicon#311 refused to E2E: 23/23 on both platforms, against
|
…still verifies (#354) Found by testing tan the way a customer does -- a pristine `ubuntu:24.04` container with nothing pre-installed. Every HTTPS call failed `CERTIFICATE_VERIFY_FAILED`, so `tan sdk list --online` and the whole `sdk install` path were dead on any minimal container or CI base image (`ubuntu`, `debian:*-slim` and friends ship no `ca-certificates`). tan SHIPPED a CA bundle and did not USE it. `net.py` treated certifi as an ALTERNATIVE to truststore, reached only if truststore "fails to construct" -- its own docstring's words. But on a host with an EMPTY OS trust store `truststore.SSLContext(...)` constructs perfectly well: it defers to the platform verifier, and that verifier simply has no anchors. The failure lands later, at VERIFY time inside `urlopen`, which no `except` around construction can observe. So the floor was never underneath anything. certifi is now loaded into the SAME context, which widens the anchor set rather than replacing it: a populated OS store -- the corporate-CA case #304 deliberately 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 already took from `crates/tan-cli/src/http.rs`; the old fall-back shape could not express it. Measured: 0 usable anchors before, 119 after. NOT caused by the --onedir change in #349, and worth recording precisely because the shape invites that assumption: the published v0.5.0-rc4 --onefile asset reproduces it byte-identically in the same container (`_ssl.c:1010` vs `_ssl.c:1000`). It has shipped in every RC. The e2e suite could never have caught this. Every host it had run on -- both my machines, and CI -- has a system CA store; a container is the first genuinely clean host tan has been tested on. That is the actual lesson here, not the four lines of code.
…leaking an account (#355) Both found by running the e2e inside a PRISTINE ubuntu:24.04 container -- the customer's real first ten minutes, with only what the quickstart says to install. 17 passed, 5 failed, and all five cascaded from one refusal. tan-cli#355. On a host with no toolchain, bootstrap said: Missing required tools: cmake ninja xz wget. Install them and re-run. and stopped. The detection is correct and is not the defect. The dead-end is that tan SHIPS the fix -- `doctor --build --fix`, added by tan-cli#91 precisely to run the manifest-owned install commands for these tools -- and never named it. A first-time customer got four package names and was left to work out their distro's incantation while the command that would do it sat one subcommand away. Same shape as tan-cli#305. `posix_refusal`'s docstring pinned the old wording deliberately -- "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." That was right when tan had no installer. tan-cli#91 changed the fact, so this adds a SECOND line and only a second line: the oracle's own first line is still emitted byte for byte, double space and all. The per-tool commands stay out of the prose exactly as before, in the structured payload where alp-sdk#959 put them. test_the_posix_refusal_stays_one_line_with_two_spaces_before_install asserted the refusal is exactly ONE line -- the intent tan-cli#91 invalidated. Inverted with its reasoning rather than left to fail, and it now asserts the double space explicitly, since a reflow would eat it silently and take the oracle match with it. Separately, and worse: tests/gates/test_no_leaked_host_paths.py caught MY OWN vendoring. Committing the e2e harness in 59ee11f put `/home/caner`, `/Users/Caner` and a `C:/Users/Caner` .cmake path into tracked files in a PUBLIC repo whose history is permanent. Six sites across scripts/e2e-full.sh and scripts/e2e-linux-freeze.sh, now derived instead: ZEPHYR_SDK_INSTALL_DIR / ZEPHYR_SDK_VERSION / TAN_CHECKOUT with $HOME fallbacks, no account named anywhere. Re-verified the freeze script still builds after parameterising (tan 0.5.0-rc4, 15180355 B). That gate is the reason the leak lasted one commit instead of reaching a tag, and it is exactly the class the same gate caught before in tan-cli#33's history purge. Vendoring the harness was right; vendoring it unreviewed was not. Suite: 2452 passed before this change with the leak gate red; green now.
|
Thorough Python-port review at Filed findings:
Reproduced evidence:
Release-state blocker not split into another issue: the branch still reports |
tan-cli#364. `feat/v06-batch` was authored largely from a Windows box whose `core.autocrlf` is `true`, and it CRLF-converted 33 tracked files that were pure LF on `origin/dev` -- `.github/workflows/release.yml`, `python/tan/cli.py`, `doctor_cmd.py`, `flash_cmd.py`, `run_cmd.py`, `scaffold_cmd.py`, `core/bootstrap.py`, `core/flash_plan.py`, four parity oracle fixtures, and whole test modules. Nothing about it was functional, and that is exactly why it mattered: every one of those files then rendered as a full-file replacement. The 38-line `bootstrap.py` change in 624d2c2 showed up as 3668 changed lines and its 25-line test change as 4495, inside a 105-file review. `git diff --check origin/dev...HEAD` reported 11,788 findings. The real diff was not auditable. Fix is the repo default rather than 33 individual corrections: `* text=auto eol=lf` at the top of `.gitattributes`, then `git add --renormalize .`. Every tracked file here 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; a binary asset added later needs its own `-text` line, and the header says so. The pre-existing specific `text eol=lf` pins are kept, not folded into the default. Each records why its path must be LF *regardless* of what the default happens to be -- byte-exact vendored trees diffed against alp-sdk, `install.sh` executing on a customer's POSIX host, the completion-script gate anchored on a bare `\n`. A future default change must not silently take those with it. Verification: `git diff --cached --ignore-cr-at-eol --stat` lists `.gitattributes` and nothing else -- the other 33 files changed line endings and no content. Zero CR bytes remain in any staged blob.
) tan-cli#358. The harness printed `=== Linux: 23 passed, 0 failed ===` while at least two named assertions proved nothing. A harness that cannot fail is not evidence, and this one had been reporting green over a broken command for two rounds. `jrun` scored a call PASS on "stdout parsed as JSON and stderr was empty". It never read RC, `ok` or `exitCode`. So tan flash --dry-run --format json run from the PARENT directory with no `--project` -- planning against a tree with no board.yaml -- returned ok:false / exitCode 1 / `flash.manifest-not-found` and printed `PASS flash: one envelope, 0-byte stderr (exit 1)`. `jrun` now takes an expected exit code, or `any` where the correct answer legitimately depends on the host (a bare container's `doctor` exits 4; a provisioned one exits 0 -- pinning either number would make the harness lie on the other host). `any` waives the NUMBER and nothing else. Enforced on every call either way: * stderr empty, stdout exactly one JSON envelope; * `envelope.exitCode` EQUALS the process exit code -- the CLI-wide invariant, and checking it here is what makes a silent divergence impossible to score as a pass; * `envelope.ok` is true if and only if the process exited 0. `sdk list --online` is the one hard `0`: reaching the release index over real TLS is the #304 CA canary and has exactly one correct answer everywhere. The `#322` leg compared the two roots for NON-EMPTINESS. A real run printed doctor=.../proj/alp-sdk against bootstrap=.../proj/alp-workspace/alp-sdk -- two different checkouts, the exact disagreement #322 exists to catch -- and scored `PASS #322: both resolve an SDK`. It asserts equality now, and prints both values when they differ. `flash` targets `--project blinky-e2e`, the project the build leg actually built, and asserts `project.root` ends in blinky-e2e and `project.boardYaml` resolved. The exit code there does depend on whether the build produced an artefact, so it stays `any` -- but WHICH project it planned against does not depend on the host at all, and that was the defect. The alp-sdk clone was an unpinned `--depth 1` of whatever the default branch was that hour, so a result could not be reproduced later from the tan SHA alone. `ALP_SDK_REF` (default `dev`) pins it and the resolved SHA is printed with the result. `e2e-linux-freeze.sh` defaulted to a fixed `$HOME/tan-cli` and then unconditionally `git fetch`ed + `git checkout -B v06 origin/feat/v06-batch`. Both are the same bug: it measured what had been PUSHED, in a tree that was not necessarily the one under test, so a local fix could pass an e2e it had never been built into. It now operates on its own checkout (resolved from the script's location), freezes what is there including uncommitted work, says so in the banner, and fetches an explicit ref only when `TAN_E2E_REF` asks. New `scripts/e2e-container.sh` runs that same harness -- bind-mounted, not copied, since a drifted second copy was part of this issue -- inside a pristine `ubuntu:24.04` carrying only what the quickstart says to install (ca-certificates, git, python3; python3 is the HARNESS's envelope parser, not a tan prerequisite). No west, no cmake/ninja/dtc/gperf, no Zephyr SDK: providing those is bootstrap's job and whether it does is the test. That container is where tan-cli#354 and #355 were found, both of which every green developer-host run had missed and #354 of which had shipped in every RC. Still open on #358 and not claimed here: wiring this into CI on the platforms the harness claims to cover.
…n Linux (#370) tan-cli#370, and it is a defect in my own tan-cli#355 fix, found by reading what alp-sdk's manifest actually contains rather than what the sentence assumed. #355 added a second line to bootstrap's missing-prerequisites refusal: Missing required tools: cmake ninja xz wget. Install them and re-run. Or run `tan doctor --build --fix` to install them from the SDK's manifest. On Linux the second sentence is false. alp-sdk `dev`'s `metadata/bootstrap.json` declares `prerequisites.install.linux` as six `sudo apt-get install -y ...` entries, and `doctor --build --fix` deliberately REFUSES to spawn anything whose first word is `sudo` (`doctor_cmd.fix_needs_sudo_check`, `doctor.fix-needs-sudo`) -- under `--format json` this process's stdio is captured end to end, so a password prompt would hang forever rather than fail loudly. That refusal is correct and is not what this changes. What it changes is the sentence pointing at it. On Linux `--fix` installs nothing; it prints the exact command per tool. Saying "to install them" there trades one wrong expectation for another, which is precisely what #355 set out to stop -- and it does so on the host most customers are on and the one the container e2e represents. A customer runs it, sees six warnings and nothing installed, and reasonably concludes the tool is broken. Keyed on the COMMANDS, not on the platform. macOS is POSIX and its `brew install ...` needs no elevation, so it keeps the plain wording; Windows `winget` is user-scope and keeps it too. A platform switch would have been wrong for macOS in the opposite direction, and reading the commands is also what keeps this correct against a manifest nobody here has seen -- the manifest is alp-sdk's to change. 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). The oracle's own first line is still emitted byte for byte, two spaces before `Install` included, and the existing parity test still pins it. Three tests, transcribing alp-sdk `dev`'s real install maps rather than inventing them: the Linux map must not promise an install and must give the `sudo` reason; the macOS map must still promise one; and a missing tool the manifest has no command for must not tip the wording toward elevation (it contributes generic advice, never a spawn -- guarding the `install.get(tool, "")` default). The first fails against HEAD, whose single unconditional constant contains no such wording. Suite: 126 passed across tests/core/test_bootstrap.py and tests/commands/test_bootstrap_command.py.
…h losing a relative path (#359, #361) tan-cli#359. `tan pinmux` built its table path as Path(sdk[0]) / "metadata" / "pinmux" / f"{resolved_family}.yaml" An absolute `--family` DISCARDS the SDK prefix outright under pathlib semantics, and `..` components walk out of it. Nothing checked either. So tan pinmux --sdk-root <sdk-A> --family <sdk-B>/metadata/pinmux/aen --format json returned rc=0, pads=96, issues=[] with `sdkRoot` naming sdk-A while the table came from sdk-B -- and any schema-shaped YAML anywhere on the filesystem could be read and surfaced the same way. `sdkRoot` stopped identifying the metadata source, which is the one thing it exists to do. Two independent checks, because either alone has a hole: the stem is validated (no separators of EITHER kind, no drive-relative or rooted drive path, no UNC, no `.`/`..`), and the resolved path is then re-verified to sit under `<sdkRoot>/metadata/pinmux`. The stem check cannot see a symlink inside the SDK; the containment check alone would still read a traversal-shaped name before rejecting it. Rejection emits one coded validation issue and reads nothing. The raw string is checked for BOTH separators regardless of host: pathlib on POSIX does not treat `\` as a separator, so an `os.sep`-only check passes on Linux while leaving Windows unprotected. Tests cover POSIX absolute, Windows drive-relative and rooted-drive, UNC, and `../` traversal. tan-cli#361. `verify_binary.sh` documents relative examples (`dist/tan/tan`), stored the argument verbatim, passed checks 1 and 2, then `cd`ed into a temporary project and died: == 3/5 init writes the vendored template tree scripts/verify_binary.sh: 96: ./dist/tan/tan: not found FAIL: init exited non-zero An absolute path passed all five. CI happens to pass `$PWD/...`, so it could never catch the invocation the script's own usage text documents. Both arguments are canonicalised before the first `cd`, not lazily at each use site -- a later contributor adding check 6 must not have to remember. The canonicalisation is the portable cd-dirname/pwd idiom, not `readlink -f` (GNU only; BSD readlink has no `-f`) and not `realpath` (absent from a stock macOS), so it works in bash 3.2, dash and Git Bash as the acceptance criterion requires. Both fixes verified fail-before/pass-after. POSIX-only tests re-run under WSL Ubuntu-24.04, where they execute rather than skip.
…the contract gate keying on source formatting (#360, #363) tan-cli#360. `doctor_cmd.run_fix` resolved the first word of each manifest install command through `on_path` and, if it could not resolve it, silently continued: resolved_exe = on_path(argv[0]) if resolved_exe is None: continue emitting no outcome check at all -- and a test pinned that silence. alp-sdk's manifest uses `brew install ...` on macOS and `winget install ...` on Windows. A fresh macOS without Homebrew, or a Windows image without a usable winget, reports tools missing, ACCEPTS `doctor --fix`, and then produces no `fix:*` check for anything. The customer sees the original failures with no indication that `--fix` did nothing, and cannot tell "nothing needed fixing" from "tan could not find the installer needed to attempt any fix". The least-equipped hosts -- the entire audience for `--fix` -- got the least diagnostic behaviour, and it contradicted `run_fix`'s own stated invariant that every entry is either run or refused and every outcome becomes a check. Now an unresolved installer emits a named outcome carrying the installer AND the affected tools, in text and JSON, deduped so one absent `brew` covering six tools does not print six identical paragraphs. The remedy names what to install (Homebrew on macOS, App Installer/winget on Windows), not merely that something was not found. The test that pinned `[]` asserts the verdict instead. tan-cli#363. `cargo test --locked --workspace` failed on Linux, Windows AND macOS with RESERVED ISSUE CODE `sdk.network-required` is gone: python/tan/commands/sdk_cmd.py no longer contains "Issue(\"sdk.network-required\", \"warning\", ...)" outside comments. The emission was not gone. It had become an ordinary multiline call. The Rust gate substring-matched each registry entry's prose `literal` against Python source, so a line wrap turned a live registered code into a stale-code verdict and made source FORMATTING part of the wire-contract gate. The tempting workaround -- rewrapping production code to satisfy a scanner -- was rejected in the issue and is not what this does: `sdk_cmd.py` is untouched. The Rust side now delegates python-side entries to the Python gate, which parses structurally, and the delegation is checked from both ends: contract.rs asserts a total partition, that the delegate file exists and carries the expected `def`, and that both halves are non-empty; the Python gate asserts the same partition mirror plus a floor. An `emittedBy` convention change reddens both rather than opening a gap. The delegation pin proves the delegate EXISTS, not that it RUNS -- a skip decorator would satisfy the substring while checking nothing, and skips are green here. Filed as tan-cli#372 with the two other findings from that verification rather than papered over.
…actually configures (#309) tan-cli#309. `tan init --template minimal-app` emitted a project whose `src/features/app_bootstrap.c` was never compiled or linked, while its own `board.yaml` declared `os: zephyr`. TWO bugs stacked, and fixing only the visible one made it worse. `board.yaml`'s `app:` decides which `CMakeLists.txt` `west build` configures, via `_zephyr_app_dir` (`tan/planner/orchestrator.py`): it picks the `app:` path ITSELF whenever that path has its own `CMakeLists.txt`, falling back to the parent only when it does not. `minimal-app`'s `src/` ships one, so `app: ./src` sent west straight at `src/CMakeLists.txt` and never at the root file. The sources were named in a dead `set(ALP_APP_SOURCES ...)` list that Zephyr's real `app` target never reads, and CMake configures that shape without complaint -- so exit 0 was never evidence anything Zephyr-shaped had happened. A first attempt fixed the root `CMakeLists.txt` to a real Zephyr shape. That file is dead code in this build, and it left `src/CMakeLists.txt` -- the file west DOES configure -- as a bare `target_sources(app PRIVATE ...)`, which real cmake 3.25.0 rejects: CMake Error at CMakeLists.txt:1 (target_sources): Cannot specify sources for target "app" which is not built by this project. That would have replaced a silent mis-build with a hard configure failure, and -- because the slice then never exits 0 -- would have bypassed the `os: zephyr` guard at `python/tan/commands/build/execute.py:782-809`, costing the customer its actionable message too. Caught by review, not by the suite. The fix is `app: .`, so the root file is the one west configures and `src/CMakeLists.txt`'s `target_sources(app ...)` binds to the `app` target the parent creates via `find_package(Zephyr ...)` + `project()`. The eight-file list and its golden are untouched -- they pin path and kind, not content or `app:`. Verified by building, not by asserting: `cmake -S <project> -B <build> -DBOARD=qemu_x86 -G Ninja` reaches `Configuring done` / `Generating done`, `ninja` links, `nm app/libapp.a` shows `app_bootstrap_step` as T (defined) alongside `main.c.obj`, and `build.ninja` feeds `CMakeFiles/app.dir/src/features/app_bootstrap.c.obj` into the archive Zephyr pulls in with `-Wl,--whole-archive`. `zephyr.elf` links. The new test runs the REAL `_zephyr_app_dir` against a scaffolded tree, so the scaffold and the planner can no longer disagree silently about which file gets configured -- the gap that let the first attempt look green. This diverges from `crates/`, which still emits the pre-#309 shape and is frozen. Nothing in either language compares the two implementations' emitted file CONTENT (the envelope golden pins path and kind; byte-parity covers only the vendored trees, and `minimal-app` has none), so there is no gate to declare the divergence to -- filed as tan-cli#371. #309's own "Measured" table conflates two build slices; corrected on the issue.
…irst-install gate certifying a released binary (#356, #362) tan-cli#349 switched the release to PyInstaller `--onedir` archives, and both installers plus the npm shim began requesting the new shape unconditionally. No published release has it. `v0.4.1` is `latest` and publishes raw binaries; the published `v0.5.0-rc4` does too -- measured, not assumed: .../v0.5.0-rc4/tan-x86_64-unknown-linux-gnu.tar.gz -> 404 .../v0.5.0-rc4/tan-x86_64-pc-windows-msvc.zip -> 404 .../v0.5.0-rc4/tan-x86_64-unknown-linux-gnu -> 200 So the documented install command broke for every tag that exists, and the npm shim regressed at ITS OWN pinned version -- the old code fetched an asset that exists, the new one a 404. One rule, one source, three consumers: `install.sh`, `install.ps1` and `npm-shim/postinstall.js` now look each candidate name up in the release's own `checksums.txt` -- archive first, raw as fallback -- and take the shape the release actually published. Never from the version, which is a second source of truth that drifts and would break a pin nobody remembered to add. The shim already fetched that file and already had the lookup, so this costs zero extra requests: fetch checksums, choose the name, then download. Fail-closed ordering is preserved and tightened: the sha256 tool is checked before the download, and the digest is compared before any unpack and before any write to the install dir. A release listing NEITHER candidate refuses loudly, naming both names tried. The transition tag is `v0.5.0`, which is not cut yet. Three files asserted that rc4 already shipped archives -- provably false against the live release, and the exact claim #356 exists to delete. Corrected in `postinstall.js`, `npm-shim/README.md` and its test. BLOCKER, and worse than the installers themselves. `.github/workflows/getting-started.yml` is this branch's own first-install gate: it runs `./install.sh`, then swaps the PR's freeze in over the top. That swap assumed install.sh had left a onedir layout. After the fix it has not -- `latest` is v0.4.1, install.sh takes the raw branch and creates no `tan-cli-lib` at all, so the copied freeze is orphaned and `tan --version` reports `0.4.1`. Since `crates/` carries every subcommand the job then exercises, it would have run clean to `all asserted` HAVING TESTED NONE OF THE PR -- precisely what that workflow's own comment says it exists to prevent. It now writes the launcher itself regardless of layout, and fails loudly with `::error::` if `command -v tan` is not the expected path or `tan --version` does not equal this checkout's `TAN_VERSION`. A silently-green gate is worse than a red one; that one would have covered the whole release. Also: the npm shim's rollback could delete the install it had just preserved (a failed rollback rename propagated into a `finally` that removed the staging dir, taking `stage/previous` with it); the `.zip` path is now tested with a genuine PK-signed zip built in-tree rather than a renamed tarball, which had been passing only because bsdtar content-sniffs; the widened "no asset for this platform" refusal is now actually reached by tests on both scripts (reverting it used to leave all 12 green); and the bare-`latest` fixture now FAILS with the tag to add rather than skipping green when `latest` moves. musl: the refusal still fires before the tag is resolved, so `--version v0.4.1` on Alpine is refused for a tag that genuinely published `-musl`. Deliberate -- "works for old tags, refuses for new ones" is a worse promise than one tag-independent refusal -- and now stated in both the message and `docs/release-contract.md` as a revisitable decision rather than a platform limit. Verified: npm shim installs and runs at v0.5.0-rc4 over the real network (`tan 0.5.0-rc4`, was 404); shim tests 11 -> 18 passed; installer tests 15 collected, 7 passed on Windows and the 8 POSIX cases re-run under WSL Ubuntu-24.04, where they execute rather than skip.
…361) tan-cli#361 survived because all three release call sites passed `$PWD/...`, so the invocation the script's own usage text documents was never exercised. The windows job now passes relative arguments and linux/macos keep absolute ones, so the release path itself covers both forms. Git Bash is also the shell the fix's cd-into-dirname idiom is least obvious on.
…ter the codes on the wire (#357, #374) tan-cli#357. The Python `support-bundle` hardcoded `ExitCode.SUCCESS` after writing the bundle, so automation saw `ok:true` / exit 0 while the same envelope carried error-severity issues -- against an oracle that makes `doctor.summary.fail > 0` a doctor failure. And it substituted `doctor_cmd._collect`'s build/flash-readiness checklist for the oracle's DEBUG-focused report, dropping `projectSelected`, `debuggerExtensions` and the CodeLLDB/LLDB checks the command exists to collect. Measured against the live oracle, same project, empty PATH: RUST rc=4 ok=false exitCode=4 PY rc=0 ok=true exitCode=0 -> now rc=4 ok=false exitCode=4 tan-cli#374, found verifying that fix. It was right about the failing host and wrong about the healthy one: `exit_code_for` was fed the Python-only-widened `longPaths` check (tan-cli#306), which is not one of the oracle's fail axes, so the port exited 4 where the oracle exits 0 -- on a fresh `HOME` with no global `.gitconfig`, i.e. exactly a first-run customer. Invisible on a developer box carrying `core.longpaths=true`, which is why the parity test never saw it: it pinned the ONE scenario where both sides are already 4. `longPaths` is now capped `fail -> warn` before it feeds the report, not filtered out of the exit code alone, so the written bundle, `summary`, `nextSteps` and the wire verdict stay mutually consistent. Measured after: both sides `rc=0 ok=true exitCode=0`. A second parity case now runs against a resolvable SDK where the oracle returns 0, so the divergence has somewhere to be caught. Four unregistered codes were reaching the wire -- `support-bundle.sdkRoot` (ERROR), `.jlinkBackend`, `.openocdBackend`, `.pyocdBackend`, plus three more reachable (`.gdb`, `.gdbserverBackend`, `.noneBackend`) -- while `pytest tests/gates/ -q` reported `11 passed`, because the gate hand-declares this command's value space instead of deriving it. All seven registered; the suffix set re-derived from `_debug_doctor_report`'s actual construction; the 14 rows for checks this command can no longer emit retired, along with the comment citing `_doctor_section`, a function this change deletes. Verified both directions rather than assumed: with the corrected suffix table but the old registry, the gate fails naming exactly those seven; re-adding a retired row makes the still-emitted gate fail correctly. `bootstrapManifest` stays as a port-only check and is now DECLARED in the parity file rather than passing silently because the old scenario resolved no SDK. `_extension_check`'s detail now carries the real U+2014 EM DASH the oracle writes, not `--`; a string ported from the oracle copies it byte for byte, unlike this file's own prose. Every other unit test monkeypatches `_collect` with a two-check stub, so none of them could notice a check entering or leaving the bundle -- which is how both the exit-code divergence and the undeclared check got through. One test now runs the real `_collect` and asserts the oracle-shaped name list. Also lands the gate half of tan-cli#360, which belonged with `doctor_cmd.py`'s 55th `Check(...)` in 6f791cf and was split off it because this file was being edited concurrently. That split is what turned `python` and `seam1 -- plan-shape parity` red on the last push: `expected 54 call(s) to Check(...), found 55`. A hand-maintained count and its subject must move together -- exactly what #374 says about deriving these instead of declaring them. Suite: 377 passed, 3 skipped across tests/gates/, the support-bundle command tests and the oracle-parity file.
…oying the sign record (#365-#369, #373) Closes the SETOOLS auto-sign gap tan-cli#353 opened, and the five defects the first pass at it introduced or left open. tan-cli#365. `sign_slot0` decided `app-gen-toc` had succeeded by re-reading two fixed paths, neither of which proves THIS spawn wrote them: a stale `app-package-map.txt` plus a stale `AppTocPackage.bin` from a previous run satisfied both checks, so a soft-failing re-sign returned the OLD address with the OLD blob and `plan_alif_mram_jlink` burned that mismatched pair into on-die MRAM -- recoverable only by re-provisioning over SE-UART. tan-cli#373, and this is the part that matters: the first fix DELETED both files before spawning, so their reappearance would prove the run. That is correct for `AppTocPackage.bin`, which `app-gen-toc` overwrites whole each run, and WRONG for `app-package-map.txt`, which this repo already documents as append-mode at `flash_plan.parse_atoc_start_address` -- "a re-signed re-run APPENDS a fresh block rather than truncating the file", which is why that parser takes the LAST match and why every bench script ends `| tail -1`. It is the accumulated sign record for that install, hand-runs included. Deleting it had its own MRAM path: a two-entry manifest where entry A supplies `flash_args.atoc_map` pointing at that file plus its own `flash_args.atoc`, and entry B auto-signs. The unlink wipes the map, `app-gen-toc` recreates it with only B's block, and A then parses B's address and pairs it with A's blob. Two different ways to write a mismatched ATOC, the second introduced while fixing the first. Now the map is SNAPSHOTTED, never removed: `(mtime_ns, size)` before the spawn, and an unchanged snapshot after a zero exit is the soft-failure signal. An append moves both, so it detects exactly what the unlink did and destroys nothing. The blob keeps the delete-then-check treatment, with the difference between the two files reasoned in the comment rather than assumed. Verified in an isolated repro: pre-fix the map is gone afterwards; post-fix the guard fires AND the map's content is byte-identical. tan-cli#366. `--dry-run` returned `ok:true` exit 0 for manifests it never validated, whenever `SETOOLS_DIR` happened to resolve -- so the defect was invisible to anyone without SETOOLS installed. The first fix narrowed it without closing it: `jlink_speed: "fast"` and `confirm: "true"` still passed a dry run and were refused on the real one, AFTER `app-gen-toc` had already run and written into the customer's SETOOLS install. Both checks are now hoisted into `validate_flow_d_shape`, the one function both paths call first. tan-cli#367. `plan_alif_mram_jlink` and `_resolve_flow_d_atoc_via_setools` each carried their own copy of the sibling-`.bin` resolution and NEITHER restricted it to ELF, so a `.hex` resolved exactly like an ELF -- while the comment, the commit message, and a test literally named `test_a_hex_artefact_is_refused_even_with_a_sibling_bin` all said it was refused. One `resolve_slot0_binary` now, and the SETOOLS path receives the resolved shape instead of re-deriving it, so signing and writing cannot disagree. Per #367(a) the accepted shapes are no-extension, `.elf` and `.out`; a `.hex` is refused, naming both what it got and what it wanted. tan-cli#368. `--setools-dir` added, precedence flag > env > manifest -- the manifest demoted deliberately, since `tan build` regenerates `system-manifest.yaml` and a customer's edit there does not survive. The resolved source is now reported on a SUCCESSFUL sign too, not only in a failure message: a stale `SETOOLS_DIR` silently winning over a working manifest field was invisible. tan-cli#369. `missing_tool_message` claimed "this does not look like an Alif Security Toolkit install" for a directory that does not exist, for a path pointing at the binary rather than its parent, AND for a genuine Windows install -- `find_app_gen_toc` never tried `app-gen-toc.exe`. It now branches on `os.path.isdir` and names the exact candidates tried. Paths reaching J-Link Commander are quoted when they contain whitespace, so a SETOOLS install under `C:\Program Files\...` stops rendering `loadbin C:\Program`. The remediation rewrite over-reached and #373 caught it: that tail is the shared fallback for FOUR banners, not one, and replacing all four deleted the correct advice for three of them -- telling a customer with an unset `jlink_serial` on a multi-probe host NOT to pin it, when pinning it is the fix. Now branched: the cloned-serial/SW-DP-ID text fires only where a board actually answered with a different DP ID; the probe-selection sentence is restored for the unrecognised-banner, target-level and refused-serial cases. Three tests had docstrings promising the original remediation and bodies asserting its replacement -- the same name-versus-body contradiction #367 was filed over, reintroduced by the PR fixing #367. Corrected. `docs/setools.md` (new) documents SETOOLS as license-gated, obtained from Alif and never redistributed here, with the sourcing precedence and the cloned-serial caveat; README now points at it, so the tool is discoverable without failing first. Suite: 2528 passed, 179 skipped, 9 xfailed, 0 failed. Reported, not fixed here (doctor_cmd.py, another change in flight): `setools_check` reads only `os.environ["SETOOLS_DIR"]` and so misreports for a customer using the new flag, and `:1071` still carries the verdict-without-evidence phrasing removed from `setools.py`.
The single v0.6.0 PR. Absorbs #331 (rebased) and carries every remaining open
v0.6.0 issue except #253 (
tan model), which stays with #58.Opened early and deliberately incomplete so CI runs continuously against it.
The checklist below is the scope; e2e on both platforms runs last, against
frozen binaries built from this branch, and its result is posted here.
Absorbed from #331 (rebased onto
dev)#224 #308 #309 #311 #312 #313 #324 #213, plus #180 / #179 / #139 closed as
already-fixed. #318's
crates/change is dropped:devalready carries theequivalent from #333 in a stronger form (it keeps the byte-identity marker
assertion alongside the
wait_until_spawnableguard), socrates/is unchangedagainst
devon this branch.The rebase was not mechanical — two fixes to the same block
#331 and the merged #336 both edit
execute_slices. Git merged them cleanly,textually, and the result was wrong in two ways:
build does not fill ZEPHYR_BASE/EXTRA_ZEPHYR_MODULES for the CMake child from the resolved workspace, so a stale ambient $ZEPHYR_BASE still wins #308's fill was cancelled by a dangling $ZEPHYR_BASE fails the SECOND Zephyr slice of a multi-core build while the first succeeds #336's pop. build does not fill ZEPHYR_BASE/EXTRA_ZEPHYR_MODULES for the CMake child from the resolved workspace, so a stale ambient $ZEPHYR_BASE still wins #308 fills
ZEPHYR_BASEfrom theresolved workspace as a gap filler merged through
assemble_slice_env; a dangling $ZEPHYR_BASE fails the SECOND Zephyr slice of a multi-core build while the first succeeds #336pops an inherited
ZEPHYR_BASEand runs after that merge. Its condition testedonly
sl.env— the plan's own pins — so it could not see the gap filler andstripped it right back out, on precisely the slices build does not fill ZEPHYR_BASE/EXTRA_ZEPHYR_MODULES for the CMake child from the resolved workspace, so a stale ambient $ZEPHYR_BASE still wins #308 exists to serve. The
pop now tests the assembled
slice_env, so only a genuinely ambient value isdropped and the two compose in order.
This was invisible to CI. Every test in
test_execute_zephyr_env.pydrivesa
backend: baremetalslice whosetoolis the interpreter itself, sois_westisFalseand the pop is never reached. Three tests usingtool: "west"are added; the first fails on the naive composition, verified byreverting the condition and re-running.
tan init --template minimal-app emits CMake that Zephyr never consumes: src/features/app_bootstrap.c is silently not compiled or linked #309's guard message was deleted. a dangling $ZEPHYR_BASE fails the SECOND Zephyr slice of a multi-core build while the first succeeds #336's re-wording block sat after
tan init --template minimal-app emits CMake that Zephyr never consumes: src/features/app_bootstrap.c is silently not compiled or linked #309's Zephyr-boilerplate guard and recomputed
messageunconditionally, so aguard-failed slice reported
failedwithmessage: None— a verdict with itsevidence removed, the same defect class as doctor:
westfails on bare PATH whilewestResolvedpasses in the same report — exit 4 on a host that builds #299 / doctor: hostPython/pythonFloor read $ZEPHYR_BASE while zephyrWorkspace reads the resolved topdir, so one report cites two Zephyrs #301 / doctor's longPaths check reads the Windows registry flag, not git's core.longpaths, so bootstrap dies on 'Filename too long' after doctor says pass #306 / bootstrap: the INCOMPATIBLE $ZEPHYR_BASE message names the verdict but not the cause, while its two sibling paths both name it #334 / doctor: a dangling ~/.alp/sdk-default is silently ignored -- the remedy assumes nothing was ever configured #344.The re-wording moves into the initial assembly, leaving the guard the last
word. CI-visible: it reds
test_the_refusal_message_matches_the_oracle_verbatim.Separately, five of #308's unit tests hardcoded POSIX literals against values the
code renders with
str(Path(...))— on WindowsPath("/sdk")is\sdk, so allfive failed the required
test (windows-latest)gate. Expectations now derivethrough the same call and mean what they say on either platform.
Remaining scope
scaffold,completion,diff,pinmux,inspect,trace,support-bundleexplain,image,size,clean,run,presets, + the above), incl.clean's duplicatedclean.remove-failedtan new-som— vendor-side SoM onboarding #254tan new-som· Deferred:tan monitor— pyserial extra and console polish #255tan monitor· Deferred:tan faultdecode#256tan faultdecoderenode --sim-mode(studio gateway)doctor --fixruns manifest-owned install commands (ADR 0021)preLaunchTaskdefaultsyocto-userspace'smiDebuggerServerAddressplaceholderdoctornames a dangling~/.alp/sdk-defaultvalidateexit 1 → 2 (BREAKING, see below)Product decisions taken (maintainer-confirmed)
--pre-launch-taskoverrides. The vscode task provider stops being a dead contribution and needs no change.validatemoves exit 1 → 2 for a failing project, with a called-out### ChangedCHANGELOG entry and a matchingalp-sdk-vscodeseverity check. CI keyed on-eq 1breaks and must be told.tannever spawnssudo— the only option that cannot hang under--format json, where a password prompt is unanswerable.scaffoldfully, module-template engine included, rather than retiring it.ADR 0021 already settles #91's other three: the Tier A/B/C consent gate, the
doctor --fix-only placement (sobuild/runcannot inherit it throughmaybe_auto_bootstrap), and no same-process re-check.Not in scope
#253 /
tan model— left to #58.Issues this closes on merge
devis this repo's default branch, so these fire automatically. They weremissing from the original body — the work was on the branch, the keywords were
not, so nothing would have closed.
Closes #77
Closes #91
Closes #138
Closes #213
Closes #224
Closes #254
Closes #255
Closes #256
Closes #257
Closes #258
Closes #259
Closes #260
Closes #261
Closes #262
Closes #297
Closes #308
Closes #309
Closes #311
Closes #312
Closes #313
Closes #321
Closes #324
Closes #344
Closes #350
Closes #351
Deliberately NOT closed by this PR
--onedirfix is here and proven on Windows (0.880 s →0.337 s) and Linux, but the 13–19 s that made it a release-blocker was
measured on macOS, and I have no Mac. Stays open until that
re-measurement lands.
tan run SETOOLS
app-gen-toc, or does alp-sdk emit a signed ATOC?)is a product decision, and SETOOLS is license-gated. Stays open for it.
tan model— NPU compile path #253 —tan model, out of scope; belongs to feat(model): tan model edge-AI lifecycle surface (check/zoo/add/prep/run/ab) — consolidated #58.