Conversation
…479) Bumps TAN_VERSION off the published v0.5.1 (to 0.5.2-rc1.dev0, with pyproject's PEP 440 rendering and the npm-shim SemVer kept in lockstep) and adds v0.5.1's real published asset list to the installer-layout RELEASES table. Both gates the v0.5.1 tag turned red were correct and neither is weakened: version-identity refused a dev tree squatting on a published tag's version, and test_sh_bare_latest_installs_whatever_shape_latest_is refused a stale bare-latest snapshot. CI evidence confirms coverage was restored rather than silenced -- skip counts identical across the red and green runs while passes rose by exactly one on both runners (ubuntu 3769 -> 3770, windows 3793 -> 3794).
A UX polish sweep across `tan`'s text surfaces. No JSON envelope moves — every change here is text-mode only, and that is asserted, not assumed. ## What a user sees differently **`tan --help` groups its 32 subcommands into six titled panels.** A flat 32-entry list is a wall; the panels say which commands set a project up, which build it, which talk to hardware, and which are for inspecting what tan decided. **`tan build --help` stopped being a changelog.** It carried `v0.4.1`, `ADDED BY THIS PORT` and `parity gap` — the Rust release this Python port replaced, which a user has never heard of. The rationale moved into the module docstring, where the next maintainer reads it; `--help` now describes the flag. The twelve deferred options stay listed: hiding a flag a user will type, then refusing it at exit 1, is worse than naming it. **Every dead-end refusal names a next step.** `tan` with no arguments, `build` with no SDK, `build` with no `board.yaml`, `sdk` on an unported command, `init` refusing to overwrite — each used to end at an exit code with nowhere to go. Each now names the command that resolves it (`tan doctor`, `tan init`, `tan examples`, `--preview`/`--force`). **`presets` and `examples` answer by default.** Both used to require a flag before saying anything useful. `examples` also gains `--category`, and an unfiltered run ends by naming the categories it could be narrowed to. **`tan doctor`'s report is wrapped, coloured, and streamed.** Three things: - The text report wraps to the terminal, on a single fixed continuation rail at column 10 — detail bodies and `fix:` lines align regardless of check-name length. Floored at 60 columns so a narrow terminal does not fall to one word per line. - The failures footer names the start of each check's own fix instead of a bare check name the reader had to scroll back up to resolve. - Checks print the moment each one completes, rather than the whole report printing after the last probe. A wedged probe is now named by the last line on screen instead of leaving a blank terminal. `--fix` streams per tool too, which is where the wait actually is: `FIX_INSTALL_TIMEOUT_S = 300` per tool across up to four of them, against `PROBE_TIMEOUT_S = 15` for the probes. ## The bug found while building it `wrap_block` passed stdlib `textwrap.wrap`'s defaults through, meaning `break_on_hyphens=True` and `break_long_words=True`. Measured against the real report at width 46: ``` run west sdk install --version 1.0.1 -t arm- zephyr-eabi to fix this issue ``` `alp-sdk`, `E1M-AEN801` and `--sdk-root` split the same way. A remedy line a customer copy-pastes then carries `-t arm-`, which is not the command tan printed — against this repo's rule that SKUs, part numbers, commands, flags and paths are reproduced verbatim. Fixed in the seam rather than in `doctor`, since `tan/core/text_layout.py` is the shared wrap point the rest of the sweep's commands are queued to adopt. `break_long_words=False` means a single token wider than `width` now overflows its line instead of being chopped mid-character: an over-long but intact path stays copy-pasteable, a silently corrupted one does not. That is a contract change for every future caller, so it is stated in the docstring and pinned by a test. ## Notes for review - **The envelope does not move.** `--format json` passes `on_check=None` to both `_collect` and `run_fix`; the streaming path adds no `checks.append`, no reorder, and changes no check's `detail`/`fix` string. Verified by hand on top of the suite: keys `command/data/exitCode/issues/ok/project`, 14 checks in declaration order, exit 4. - **`checked_codes` uses `kebab_check_name`.** The rebase put this branch's renderer rewrite on top of #461's kebab-cased issue codes, and the rewritten block reproduced the old spelling. Left alone, every multi-word check name (`sevenZip`, `hostPython`) would miss the set and the whole report would print twice. - **A print failure no longer discards a finished diagnosis.** Both stderr print sites route through one guarded helper. The failure it guards is a lone surrogate from a `surrogateescape`-decoded filesystem path, plus a closed or broken pipe — `tan.cli.main` reconfigures stderr to utf-8/strict, so the codepage story that guard was first written against measurably cannot happen. - **The module-size ratchet was re-measured, not carried over.** Five entries this branch raises had moved on `dev` in between (#461 and #464 grew exactly these files), so neither side's number nor their sum was correct. Each is the merged file's real count, with its cause recorded beside it. `_FUNCTION_COUNT_BUDGET` ends at 203 — the pre-sweep baseline — after trimming a docstring that had pushed `render_doctor_footer` past the cap. ## Verification `py -3.12 -m pytest tests -q` from `python/`, on this branch rebased onto `origin/dev`: **3344 passed, 194 skipped, 18 xfailed, 0 failed** in 612.03s. `ALP_SDK_ROOT` unbound, so the parity and relocation-freshness suites skip as they do in CI's default job.
Extends the wrap seam doctor adopted to the two widest text surfaces after it: explain (161 columns) and sdk current (137), measured on a host with no SDK resolved. Wrapping is gated on stderr being a terminal via a new tan.env.wrap_width(), which returns None off a tty -- and None means do not wrap, so a consumer redirecting the stream still gets one whole record per line. Piped output is byte-identical to before. wrap_width takes no no_color/ci parameters at all, which is what makes --no-color and --ci structurally unable to disable wrapping rather than relying on a runtime check nobody re-reads. The first draft's record-vs-prose classification was deleted rather than repaired. It kept fixed-field and catalogue lines unwrapped so a piped grep/cut could extract them whole, but it was only ever consulted when wrap_width returned a width -- i.e. when stderr IS a terminal -- and any pipe that could grep those lines makes stderr not a terminal, disabling wrapping wholesale. It never fired for the reader it was written for. Measured at 80 columns it hard-wrapped a 72-column prose line while handing the command's three widest lines (109/91/161) to the terminal to break mid-token: edge-ai-star came out as edge-ai-star / ter, the exact corruption break_long_words=False exists to prevent. doctor keeps wrapping unconditionally, documented at wrap_width rather than changed: doctor prints a REPORT whose redirected form is a file a human opens and wants wrapped, while explain and sdk current print RECORDS whose redirected form feeds grep/cut. Output shape decides; both are correct. All three command modules shrink (explain -38, sdk_cmd -54, doctor_cmd -9); the only growth is the shared wrap_lines in tan/core/text_layout and wrap_width in tan/env. Two ratchets go down. Replaces #481, closed automatically when #480 merged with --delete-branch.
's recurrence (#521) * fix(planner): re-sync tan/planner against alp-sdk 53557a60, closing #320's recurrence (#485) Ports the four live defects issue #485 names, each with a regression test that fails against the unfixed code: 1. slugs.py: add the _DRIVER_STATUS_SUFFIX filter (alp-sdk #1169) so nor_flash_driver_status/emmc_driver_status never read as a chip slug -- unfixed, the literal string "none" reached CONFIG_ALP_SDK_CHIP_NONE=y, an undeclared Kconfig symbol that aborted Zephyr configure on every V2N-family SKU. 2. loader.py + kconfig.py: refuse cacheable: true on a kind: rpmsg ipc entry (alp-sdk #1088) and widen _emit_cross_core_shmem_cache's CONFIG_DCACHE=n to cover rpmsg too, not just raw_shmem. 3. loader.py: route _load_yaml/_load_json through a new tan/planner/strict_loaders.py (hand-ported from alp-sdk #1127) so a board.yaml repeating a mapping key (e.g. duplicate som.sku) refuses instead of silently keeping the last value -- tan validate and tan build no longer disagree on the same file. 4. project_loader.py: add the SdkRevisionNotBuildable status gate (alp-sdk 1a91a232, the other half of #1025) to _hwrev_pad_route_overrides, so --target carrier-netlist/ composed-route-table refuse a reserved/tbd/status-less hw_rev the same way tan build already does. Also ports two small, low-severity companions surfaced by the same audit: carveout.py's carveout: false memory_map exclusion was already ported (unaffected); zephyr_board.py and som_metadata.py gain alp-sdk #1048's case-insensitive TBD match for silicon_variant. Re-pins python/tests/gates/test_planner_relocation_freshness.py's PINNED_SDK_COMMIT to alp-sdk 53557a60 (exactly the commit spanning the six drifted modules named in #485, chosen to stop short of validate.py's unrelated #1197 curated-library drift). Adds a third, independently pinned STRICT_LOADERS_PINNED_SDK_COMMIT/HAND_PORT_SDK_ROOT-style track for strict_loaders.py rather than folding it into HAND_PORT_HASHES, whose other three files carry real unaudited drift (alp-sdk #1125/#1126's path-traversal fix in alp_template.py, unrelated to this issue and left for a follow-up) that a shared pin bump would have silently certified away -- the exact failure mode #485 exists to name. parity.yml gains a notify-planner-drift job: on a repository_dispatch run (the one that tests the alp-sdk ref that just pushed, not the pinned tag), a red python-tests job now files or refreshes a single tracking GitHub issue instead of sitting invisible in the Actions tab, which is how six consecutive red dispatch runs went unactioned before #485. The pin-freshness warning step stays warn-only by design, unchanged; whether that warning itself should become blocking is #509's decision, not this one. * fix(ci): move sdk_parity's alp-sdk checkout to 53557a60, matching PINNED_SDK_COMMIT Follow-up to the previous commit: ci.yml's sdk_parity: true path hardcodes an alp-sdk ref and runs pytest tests -q with no --ignore=tests/gates, so it hashes scripts/alp_orchestrate/*.py at whatever this ref resolves to and compares it against test_planner_relocation_freshness.py's PINNED_HASHES. Left at the old f4d87a1f (== PINNED_SDK_TAG, unchanged), this now fails test_relocated_planner_modules_match_the_pinned_sdk_audit outright, since PINNED_HASHES moved to 53557a60's content in the prior commit -- verified both ways (fails bound to f4d87a1f, passes bound to 53557a60). Re-verified the artefact-count floor test_the_breadth_layer_still_covers_ every_board (>= 2900, the exact assertion a prior 2bc6b400 bump attempt tripped and reverted for, per this file's own history) is NOT hit at 53557a60: the full sdk_parity: true invocation (pytest tests -q, bound to this ref) now runs 1 failed, 4193 passed, 63 skipped, 18 xfailed. The one failure is KNOWN and NOT fixed here: test_an_unresolvable_board_ preset_is_a_coded_envelope (python/tests/parity/test_planner_emit_parity.py, frozen for this change) reproduces identically regardless of which alp-sdk ref is bound -- it is a direct, correct consequence of the duplicate-key refusal landing in loader.py, catching a duplicate preset: key that test's own fixture-construction logic creates by appending a second preset: line onto a board.yaml that already declares one. Flagged in ci.yml's own comment and in the report for this change; needs a human decision on that frozen fixture, not a drive-by edit here. * fix(review): close both #485 review blockers plus five accuracy findings Two mechanical blockers, both shipping red CI: 1. parity.yml: PINNED_SDK_TAG was left at f4d87a1f while PINNED_SDK_COMMIT moved to 53557a60 in the prior commit. Since examples/multicore/rpmsg-aen/board.yaml dropped cacheable: true in 53557a60 -- the same commit that added the refusal this issue ports -- every pull_request/push python-tests run bound ALP_SDK_ROOT to the OLDER f4d87a1f and correctly refused alp-sdk's own (now-stale) fixture: 31 failures, measured both sides, nothing wrong on either repo, just two refs a half-step apart. Bumped PINNED_SDK_TAG to 53557a60 (#485's own suggested step 1), re-verifying the three CI-wired byte-parity gate scripts (kconfig-fixture, toolchain-lock, scaffold) PASS unchanged at the new ref first. ci.yml's sdk_parity checkout ref moves to the same commit again for the same reason -- the two pins are back in sync after a brief, deliberate divergence earlier in review. 2. test_planner_emit_parity.py: test_an_unresolvable_board_preset_is_a_ coded_envelope's own fixture appended a second preset: key onto a board.yaml that already declared one, expecting last-key-wins to reach its intended unresolvable-preset assertion -- instead tripping the new duplicate-key refusal first. Confirmed adjudicated correct both ways: alp-sdk's own front door (alp_project.py --emit carrier-netlist) refuses the byte-identical appended fixture with the identical error, so tan and alp-sdk agree and the fixture's own premise is what broke. python/tests/parity/ carries no repo-level freeze (that's crates/, contract/, and test_oracle_parity.py's CASES table specifically) -- fixed directly: substitute the existing preset: value instead of appending. Verified both ways (f4d87a1f and 53557a60) and against the full sdk_parity: true invocation, now 0 failed. Plus the five accuracy findings from the same review: - test_planner_relocation_freshness.py's STRICT_LOADERS_* comment cited template.py functions that do not exist (render/default_sku/validate). Corrected to the two real, empirically-verified call sites (_rendered_bytes:210,214 and render_to_envelope:1091), the actual read/write split (fs_confine.resolve_confined covers every write; nothing guards these two catalog-driven reads), and a scoped threat model (a hostile SDK/catalog checkout, already trusted to run arbitrary CMake/west -- narrower than #1126's write-bug severity, but real). - HAND_PORT_PINNED_SDK_COMMIT's comment claimed zephyr_board.py and project_loader.py were frozen at 996937ac; this change forward-ports #1048 and #1025's status half into both, strictly after that commit, invisible to a gate that hashes only the SDK side. Comment corrected to say so plainly instead of implying no gap. - New test_hand_port_sources_declares_its_one_strict_loaders_exception: closes the path where deleting strict_loaders.py's own STRICT_LOADERS_* pin would leave it silently unaudited while both existing coverage gates stayed green. - strict_loaders.py's docstring said it was tracked in HAND_PORT_HASHES, exactly the opposite of what this change deliberately did -- corrected to name STRICT_LOADERS_PINNED_SDK_COMMIT. - notify-planner-drift's de-dup used gh issue list --search "... in:body", which tokenizes on hyphens and ANDs the tokens rather than matching the phrase -- measured live returning 5 unrelated issues for a similar query. Replaced with a label-scoped list + local exact-substring filter. One nit taken: restored the dropped "same error type and message shape as loader's SoM-side refusal" note in _hwrev_pad_route_overrides's docstring (load-bearing: the function inlines loader._status_repr rather than importing it), raising _FUNCTION_COUNT_BUDGET to 204 with a reason rather than re-trimming, per the gate's own invitation. Re-verified both gate runs after all of the above: unbound: 3341 passed, 224 skipped, 18 xfailed, 0 failed bound to 53557a60: 4195 passed, 63 skipped, 18 xfailed, 0 failed
…a real tag cut (#527) alp-sdk cut a real `v0.15.0` tag (e2928b9f, annotated to 3769febe; `v0.15.0-rc1` at 996937ac still exists too, unaffected). The templates stay pinned at `v0.15.0-rc1` -- it is still a tag alp-sdk actually has, and re-pinning to `v0.15.0` would mean re-vendoring 40 links across seven READMEs plus MANIFEST.md's Ref/Commit lines and its DELIBERATE_EDITS byte-parity declaration against a commit this tree was never captured at, for no correctness gain: rc1 and the final tag are the documented, browsable ref this scaffold's prose describes either way. What broke: the negative control in test_the_vendored_ref_is_a_tag_alp_sdk_actually_has derived its dead ref by stripping the vendor ref's pre-release suffix (`v0.15.0-rc1` -> `v0.15.0`). That was fine while `v0.15.0` was unallocated; now it is a real tag, so the same derivation silently produces a ref that legitimately exists and the control stops asserting anything -- the exact self-disabling failure mode a negative control exists to prevent (this is the same class of drift tan-cli#384 itself was: a derived value quietly stopped meaning what it used to). Replaced the derived `control` with a hardcoded `_DEAD_CONTROL_REF = "v0.15"` (no patch component). It keeps the same hazard shape without depending on the live vendor ref: verified against the live repo, GitHub's exact-ref endpoint (`/git/ref/tags/v0.15`) 404s, while the plural prefix-matching endpoint (`/git/refs/tags/v0.15`) still 200s by matching `v0.15.0`/`v0.15.0-rc1` -- so it still catches `_tag_exists` regressing onto the plural endpoint, which is the whole point of the file. alp-sdk's tags are always full MAJOR.MINOR.PATCH (optionally `-rcN`); a bare MAJOR.MINOR has never been cut and would break that convention if it ever were, so this should stay dead for a while. The comment on the constant says what would make it stale (a literal `v0.15` tag, or the vendored line moving far enough past v0.15.x that the prefix relationship stops applying) so the next person picks a fresh plausible-prefix ref instead of re-deriving one. No template content or link changed; only the test's control mechanism.
…530) Closes #510. Implements ADR-0020's "never PATH" Security consequence: command.tool is an identity (alp-sdk#1286), so tan resolves it to an absolute path and spawns that, through one hardened resolver rather than a check and a spawn that had drifted apart. The cwd-shadow invariant is verified on real Windows by the windows-latest pytest leg, and guarded portably on every platform by two resolver-contract tests.
…AND_SZ Path destruction, tcsh shadowing (#538) Closes #490. Verified by execution rather than inspection: real tcsh 6.24.10 across all 8 csh/tcsh x rc-file combinations, real fish 3.7.0, a real noexec tmpfs via unshare, and pwsh 7.4.6 driving install.ps1's logic. A data-loss regression found in review -- an unguarded recursive delete of $HOME/.local -- was replaced with a non-recursive leaf-to-ancestor walk and re-probed in both directions.
…e stdin reader to #537 (#539) Refs #503. Refs #537. Deliberately does not close #503 -- seven rounds on _read_implicit_stdin each closed the named defect and introduced another, so the reader is deferred to #537 with the full history of what has been ruled out. This lands only what is independently correct.
…claiming a reset that did not happen (#542) Closes #519. Closes #522. Refs #540. Refs #541. Per-tool selectors (openocd_usb_location, pyocd_uid) with wrong-arm refusals hoisted above the arm split, and Flow D's ok_message now reports the observed reset outcome instead of asserting one. Three review rounds; the tee that delivers #522's text-mode qualification is tracked for a pty follow-up in #541.
…creating it (#507) Refs #476. Half (a) only: a nonexistent --project no longer creates the tree and writes a native-host launch.json into it. Half (b) -- the native-host default for a project with no board.yaml at all -- stays open, so #476 stays open. Original author: hkngln. Finished under maintainer handover 2026-08-08.
…crash (#508) Refs #477. Refs #476. Every #477 refusal site now exits 2 rather than 5, and the pairing refusal reports the target and server the caller actually typed instead of the zephyr-mcu/none placeholder. Not a Closes: the --core-without-a-build-manifest half stays open. Guarding it there breaks two legitimate tests that pass --core with no manifest to select the SDK-published debug-probe identity (alp-sdk#1026), so #477 keeps a documented, test-pinned gap rather than a wrong fix. Original author: hkngln. Finished under maintainer handover 2026-08-08.
…esolves one (#504) * fix(sdk): disclose a foreign global default from every command that resolves one tan-cli#464 gave `~/.alp/sdk-default` a `writtenFor` field and taught doctor/generate/build/presets/examples to warn when the pointer was written for a DIFFERENT project. Five commands resolved the same foreign checkout and said nothing -- measured against the shipped v0.5.1 binary, from a real two-project bootstrap: inspect / trace / validate / diff / support-bundle sdk.sourceTier = globalDefault, ok = true, issues = [] The silence is not passive. `trace` prints the `alp_project.py` a build would run, from the other project's checkout. `validate` SPAWNS that checkout's `scripts/validate_board_yaml.py`, so another project's schemas -- at whatever revision it sits on -- decide "clean" or "violation" for this board.yaml. `diff` normalises through it. And `support-bundle`, the one artefact a user sends to someone else to explain a broken machine, carried the fact nowhere: not in `issues[]`, and not in the reduced doctor set it embeds -- that set keeps host checks only (#441), 8 where a standalone doctor emitted ~17, so depending on it would not have covered this either. No new mechanism. `resolve_sdk_root_ladder` has always answered with `foreign_global_default_for`; `sdk_resolution_issues` has always turned it into the pair. What was missing was the wiring: - `ResolvedDebugContext` now CARRIES `foreign_global_default_for` and `broken_project_pin` instead of dropping them -- one seam that covers inspect, trace and support-bundle, since all three build from it. - validate resolves the pair beside `sdk_info`, outside the `--offline` branch, so no future early return can skip it (the #464 review's own lesson from size/image). - diff carries it through `_emit_failure` as well as the success envelope: a refusal that hides which SDK it refused against is no better than a success that does. The test drives the REAL two-project sequence rather than hand-writing a pointer file -- hand-writing one proves only that the reader works. Verified both ways: 6/6 fail with the source change stashed, 6/6 pass with it. Two size budgets rise (validate_cmd 1093 -> 1108, support_bundle_cmd 834 -> 842), with the reason recorded inline. Both modules are already over the 800-line cap and named in #408; extracting from them is that issue's job, and holding a defect fix hostage to it would leave the silence in place for the sake of a number. Full suite: 3346 passed, 0 failed, 215 skipped, 18 xfailed. Closes #478 * test(sdk): compare writtenFor posix-on-both-sides, not native-vs-posix windows-latest only, and the failure was in the TEST, not the fix under it: tan writes `writtenFor` posix-normalised on every platform, while `str(proj_b)` is native, so the precondition compared `C:/Users/.../projB` against `C:\\Users\\...\\projB` and refused a state that was actually correct. Every assertion BELOW it already normalised (`str(new_sdk_b).replace(...)`); this one was the omission. macOS and ubuntu passed because their separator is already `/` -- the exact windows-only shape this repo keeps getting bitten by (reference: the five green-on-macOS/red-on-windows traps). * fix(sdk): emit the foreign-default pair from one seam, not per command Redirects #504 from the per-command sprinkle review rejected to the central seam #478 actually asked for: "from one place, so a new command cannot forget it". Shape chosen after an independent advisory read; two candidate mechanisms were rejected on evidence. REJECTED (a) re-derive at emit() off the envelope's sdk block. That block carries only {root, sourceTier}; foreignness needs `writtenFor`, so it would re-read ~/.alp/sdk-default at emit time -- a TOCTOU against a pointer THIS PROCESS mutates mid-run. `bootstrap` rewrites it with its own `written_for` before its envelope goes out, and test_bootstrap_command.py pins the resolution-time semantic ("init surfaces sdk.global-default-foreign-project BEFORE _pin_sdk writes"). An emit-time read reports the post-mutation state, so the warning would vanish on exactly the two commands that change it. REJECTED (b) per-command emission plus a gate enumerating commands. Not soundly implementable here: "resolves an SDK" flows through five entry points, and module-level call-site presence is trivially satisfied while every early-return path stays silent -- this codebase's own RECORDED defect shape (size_cmd.py:450, image_cmd.py:392, flash_cmd.py:1983 all document exactly that). The gates that work here gate literals and constructors, not reachability. TAKEN: compute at resolution time (the ladders already carry it), carry on SdkInfo as NON-WIRE fields (as_dict unchanged -- that is the extension handshake), one blessed SdkInfo.from_resolution(), and append centrally in Envelope.__init__, deduped by code so the existing hand-call sites keep their own order. Mirrors _with_sdk_divergence, whose docstring already makes this argument for #407. Proof the seam carries rather than decorates: `diff`'s hand-call is DELETED and it still discloses. Also closes the two review blockers on the bundle: - `sdkResolution` now goes into the payload BEFORE _write_bundle. The earlier revision computed it after the write, so #478's own repro (`'global-default-foreign-project' in open(BUNDLE).read()`) still answered False -- the one item review called worth fixing first. - the test named for the file now READS the file. It previously resolved bundle_path, asserted is_file(), then asserted on the envelope -- false assurance over that miss. Verified: it fails with the payload change stashed. Prerequisite checks both cleared by measurement, not assumption: no committed golden or parity fixture carries `globalDefault` (3 goldens carry sourceTier, all sdkRootFlag/none), so the central append cannot move frozen bytes; and all twelve commands in scope DO construct an Envelope, so the seam reaches them -- `build`'s text path is the one exception and is called out. Seven mechanical construction sites converted. Sixteen remain on loose locals and need the resolution threaded per command; the AST gate that refuses raw SdkInfo( lands with them. Full suite 3344 passed. Two failures triaged: size budget (mine, bumped with the reason inline) and test_the_vendored_ref_is_a_tag_alp_sdk_actually_has (NOT mine -- zero commits on those paths from this branch). Refs #478 * fix(validate): keep the SDK advisory out of the board's own documents, and gate the seam Closes the three regressions the first revision introduced (review majors 3-5), at ONE split rather than four readers: `_emit` now separates this command's own `validate.*` findings from the `sdk.*` resolution advisories. * data.issueCount reads as "how many findings does this board have". Counting a host fact made a CLEAN board report outcome "clean", exitCode 0, issueCount 1. * --format sarif / --format diagnostic-v1 are ported alp-sdk documents in which every entry is anchored at board.yaml, region 1:1. A CI job uploading the SARIF to code scanning would have annotated line 1 of the customer's board.yaml with "the machine-global default SDK was last set by a bootstrap relocation in <other project>" -- a fact about the host, rendered as a finding about the file. * the tan-cli#350 text verdict keyed off `len(issues) == 1`, so prepending the advisory pushed it to 2 and an empty directory printed "validate: validation failure" -- the wording #350 removed precisely because nothing was checked and so nothing was found wrong. The envelope's issues[] still carries both; only the board-scoped readers are narrowed. Both new tests fail with the source change stashed. Plus the enforcement Fable's advisory called the implementable half of a "command resolves without emitting" gate: tests/gates/test_sdk_info_is_built_from_a_resolution.py refuses a raw `SdkInfo(...)` outside the seam, by AST rather than substring so annotations and imports are not miscounted. It is a RATCHET -- the sixteen legacy sites are ledgered and can only shrink -- with a staleness half, because a ledger that silently over-counts stops covering what it names. It earned itself immediately: on its first run it found clean_cmd.py:738, a site absent from the hand-written enumeration this work was based on. Converted rather than ledgered, since its resolution was already in scope. Full suite 3350 passed. One failure, test_the_vendored_ref_is_a_tag_alp_sdk_actually_has, is NOT from this branch: `git log origin/dev..HEAD -- python/tan/core/ python/tests/core/test_template_integrity.py` is empty. Refs #478 * fix(sdk): disclose a foreign global default in DEFAULT text output too (#478) Closes the review's outstanding finding on tan-cli#504/#478. The JSON envelope now carries sdk.global-default-foreign-project for every command via the central Envelope seam (already landed on this branch), but five commands (diff/inspect/trace/support-bundle/validate) and the three west-forwarding verbs (migrate/lock/quality) write text mode straight to stderr or hand stdio to a spawned child, bypassing that seam entirely -- so a plain `tan trace` (no --format json) stayed silent about resolving another project's checkout, the literal repro #478 opened with. - diff_cmd.py: sdk_context_issues was left permanently empty after the prior review round deleted diff's hand-call (reasoning the JSON seam alone was enough); recomputed for real and printed in both text branches. - inspect_cmd.py / trace_cmd.py: print the sdk.* issues before their own text, unconditionally (not suppressed by --quiet). - support_bundle_cmd.py: prints from outcome.issues (NOT outcome.sdk, which is resolve_debug_project_context's legacy bare SdkInfo(sdk_root, sdk_tier) and drops both facts -- caught by the new test, first attempt printed nothing). - validate_cmd.py: the "no board.yaml to validate" branch printed findings[0] alone (per #350's narrow verdict wording) but dropped the sdk.* advisories the other two text branches already show by looping over unfiltered issues. - west_forward_cmd.py: _run_text now takes the resolved SdkInfo and prints before spawning west -- once stdio is handed to the child there is no stream left of tan's own to warn on. Module-size and function-length ratchets re-measured and updated with reasons (diff_cmd.py newly crosses 800; support_bundle_cmd.py/validate_cmd.py grew; one new >50-line function crossing, diff_cmd._emit_failure). Tests: a default-text-mode counterpart to the existing JSON-mode parametrized case in test_foreign_global_default_coverage.py (all 5 commands), plus a west_forward unit test. Full suite: 3637 passed, 0 failed, 236 skipped, 18 xfailed. * fix(sdk): thread the SDK-resolution pair through support-bundle's early-return paths PR #504 review MAJOR 1: `_internal_failure`/`_server_incompatible` built their `_Outcome` from a bare `issues=[Issue(...)]` list, dropping `sdk.global-default-foreign-project`/`sdk.project-pin-unresolved` on the three early-return failure paths (a --target-kind/--server parse refusal, an unsupported target/server pairing, and a bundle-write OSError) -- exactly the runs a customer hits when something has already gone wrong. Both helpers now take the same broken_project_pin/sdk_tier/ foreign_global_default_for fields _run's success path already threads, keyword-only and defaulted so the outer exception guard (which never resolves a project context) keeps its prior empty-list behaviour. MAJOR 2: the CHANGELOG's Fixed entry claimed the warning "now reaches every command that resolves an SDK" in default text output -- not true of every one of tan's 32 commands. Narrowed to name the eight commands this PR actually wires for text-mode disclosure (the five #478 commands plus the three west-forwarding verbs), on top of the five already wired since #464. Added a second bullet documenting today's early-return fix. Re-measured test_module_size_budget.py's support_bundle_cmd.py entry (876 -> 935, AST-walked, not computed) and added a regression test that drives both early-return shapes from inside a real foreign-global-default state, verified to fail without the fix and pass with it. Full suite: 3638 passed, 0 failed, 236 skipped, 18 xfailed. * fix(sdk): convert four more construction sites, and assert the invariant across the whole CLI Continues the seam. Four sites whose resolution was already in scope now build through it -- inspect, doctor, flash, validate -- and leave the gate's ledger, taking it from 13 raw constructions to 9. The rest hold loose locals and need the resolution threaded per command; the ratchet keeps them from growing meanwhile. The important addition is the invariant itself: `test_no_command_reports_a_foreign_global_default_without_saying_so` enumerates `cli.app.registered_commands` rather than a hand-written tuple, and asserts that no envelope may report `sdk.sourceTier == "globalDefault"` without `sdk.global-default-foreign-project` in issues[]. A hardcoded list cannot express "a new command cannot forget" -- it is precisely what the 33rd command would not be added to. This covers it the day it is registered. That also answers the sixth-silent-command finding (`west`) structurally rather than by name: it takes its `sdk` from `build_output`, which now builds through the seam, so it discloses without knowing this issue exists. Commands that cannot be driven bare -- bootstrap and init (they MUTATE the pointer under test), new-som, flash, monitor, completion, faultdecode, west -- are named with the reason in NOT_DRIVABLE_BARE, never skipped silently. And a vacuity floor, because a loop that reaches no `globalDefault` command would assert nothing while reporting green -- the exact failure this file exists to prevent, and one this session has now caught three times. The test records how many commands it actually verified and fails below five. Full suite 3351 passed. The single failure, test_the_vendored_ref_is_a_tag_alp_sdk_actually_has, is NOT from this branch (zero commits on those paths). Refs #478 * docs(changelog): drop a stray blank line the dev-merge conflict resolution left between two #476/#478 entries * fix(sdk): disclose a foreign global default from west's required-flag refusal too `_refuse_required` is what a bare `tan quality` and a bare `tan migrate` reach: `--profile` and one-of `--check`/`--preview`/`--apply` are REQUIRED by the child's own argparse, so the refusal fires before `west` is ever spawned. Its `--format json` branch disclosed the SDK-resolution pair through `Envelope.__init__`'s seam; its text branch printed `<verb>: <message>` and nothing else -- so the DEFAULT invocation, from a workspace resolving another project's checkout, said nothing about whose SDK `_plan` had just pointed `west` at. Measured on the two-project fixture: quality: `--profile` is required (`west alp-quality --profile ...`). # ... and nothing else, while --format json carried # ['quality.profile-required', 'sdk.global-default-foreign-project'] Both text paths now go through one `_echo_sdk_resolution` helper rather than open-coding the loop, so a third text path cannot be added that discloses in JSON and stays silent on the screen -- which is exactly how this one shipped. The CHANGELOG asserted this was already true for all three west verbs; it is now, and the entry names the refusal path it had glossed over. Also in this change, all found reviewing it: * `doctor_cmd`/`clean_cmd`/`inspect_cmd` had their one-line `SdkInfo` ternary wrapped in parens at a broken indentation (statement at 8, continuation at 8, closing paren at 4) for no width reason -- 97/92/93 chars against the 100-char `[tool.ruff] line-length`. Restored to one line; `doctor_cmd.py` and `clean_cmd.py` return to dev's own budget numbers and `flash_cmd.py` (101 chars, genuinely too long) keeps a two-line form at +2 instead of +4. * `support_bundle_cmd`'s text-path comment justified reading `outcome.issues` by claiming `context.sdk` is a bare `SdkInfo(sdk_root, sdk_tier)` ledgered for `inspect_cmd.py`. Both halves are false as of this branch: that resolver builds through `from_resolution` now, and `inspect_cmd.py` is not in the ledger. * the constructor gate skipped `envelope.py` by `path.name`, exempting any future `tan/<pkg>/envelope.py` from the gate for the sake of its filename. Anchored to the real path. --------- Co-authored-by: Caner Alp <109098482+alpCaner@users.noreply.github.com>
…four pins together (#582) `parity` had failed on eight consecutive `repository_dispatch` runs, 08-07T21:10 through 08-08T09:18, one per alp-sdk commit. It runs on `repository_dispatch`, not `pull_request`, so every open PR showed green over it. The live divergence was one Kconfig line. alp-sdk#1241 added a `_chip_has_driver` filter; the mirror predated it, so tan emitted `CONFIG_ALP_SDK_CHIP_DP83825=y` for every AEN board. That symbol is declared nowhere -- every entry in `zephyr/kconfigs/chips.kconfig` reads "Compile chips/<part>/<part>.c" and there is no `chips/dp83825/` -- and `ethernet_phy: dp83825` made an undriven chip reachable from `on_module:` for the first time. It is not parity-only: with the pin moved, a real `tan build` aborts at Zephyr's Kconfig stage with "attempt to assign the value 'y' to the undefined symbol ALP_SDK_CHIP_DP83825". Third recurrence of this failure mode (#320, then #485's `CONFIG_ALP_SDK_CHIP_NONE=y`). The mirror is not hand-edited: the upstream shape is ported, so the next sync does not bring the line back. Sixteen upstream commits were classified by reading each diff rather than its subject line -- two `docs(accuracy)` ones turned out to be behavioural, and one `fix(docs)` one turned out to be docstring-only. Ten behavioural ports landed; four were already present and are re-confirmed by measurement in the pin comments; two are not applicable. The four pins move together because a port without the bump, or a bump without the port, reds a seam either way: `PINNED_SDK_COMMIT`/`PINNED_HASHES`, `HAND_PORT_PINNED_SDK_COMMIT`/ `HAND_PORT_HASHES`, `parity.yml`'s `PINNED_SDK_TAG`, and `ci.yml`'s `sdk_parity` checkout ref, which that file's own comment requires to agree with the third. `STRICT_LOADERS_HASH` is deliberately untouched -- `scripts/strict_loaders.py` has not moved since its own pin (0 commits in the range). alp-sdk#1289 is the highest-stakes one. SETOOLS top-anchors the ATOC application package at the App MRAM window end (`0x80580000` on the E8) and grows it DOWNWARD at provisioning time, so the layout must reserve a band, not an address. Bench-observed on E1M-AEN801 2026-08-08: ATOC magic `ckBS` (`0x53426B63`) intact at `0x8057EA50` while a Zephyr app erased `0x80560000` inside the SAME `storage` partition. Either direction leaves an unbootable part, silently, until the next boot. `_AEN_STORAGE_KIB` 128 -> 96 with a new `_AEN_ATOC_KIB = 32`, so the reserved total still sums to 256 KiB, `image_kib` is unchanged and no committed AEN board's slot geometry moves. The measured evidence is kept verbatim from alp-sdk rather than summarised. alp-sdk#1331 closes a silent-corruption default in the storage resolver: it checked page alignment, device capacity and sibling overlap, and nothing else, so a littlefs mount resolved onto MCUboot at offset 0 of `mram_main` with `status` clean and no `offset_kib:` declared anywhere. The bump allocator now advances PAST the SoM's own regions; the device origin is derived then VERIFIED (`origin + capacity == highest region top`) and the conversion refused with a warning rather than guessed. `python/tan/templates/vendored/` is re-vendored in the same commit, since alp-sdk#1266 and #1287 change `--emit scaffold` bytes. tan-cli#384's seven `DELIBERATE_EDITS` entries are RETIRED rather than re-pointed: they existed because the emit rendered links at a `v0.15.0` tag alp-sdk had never cut, and alp-sdk has since cut it (`e2928b9f`), so the divergence is healed and that module's own doctrine makes a stale excuse a hard failure. `edge-ai`'s `testcase.yaml` is RETAINED -- its catalog record still carries `testcase_yaml: ["examples/ai/cold-chain-monitor/testcase.yaml"]`. tan-cli#425's recorded `seam1_field_diff.py` divergence was re-measured and no longer reproduces: `multicore_rpmsg-imx93` now reports "alp-sdk and tan both refuse this board", both sides quoting the same `status: 'tbd'` refusal, because tan-cli#485 had already forward-ported alp-sdk#1025's status half. That issue's body is stale in two more places (it names `f4d87a1f` as the pin, moved since); the corrections are written into the pin comment rather than left to the reader. Verified against alp-sdk `f30f4d4b`: - `--emit build-plan` byte-identical for `examples/aen/*` (the DP83825 line present before, absent after); 0 of the AEN Zephyr slices emit `CONFIG_ALP_SDK_CHIP_DP83825`, with the neighbouring chip lines intact - `seam1_field_diff.py` rc 0, `scaffold_byte_parity.py` rc 0 (9/9 pairs), `kconfig_fixture_parity.py` rc 0 (814 bytes), `toolchain_lock_parity.py` rc 0 (6592 bytes) - `python -m pytest tests -q`: 4483 passed, 77 skipped, 21 xfailed, 0 failed - `crates/`, `contract/envelopes/` and `python/tests/parity/oracle_fixtures/` untouched: `git diff origin/dev...HEAD` over them is 0 bytes - every size-budget pin re-derived by measurement, including `_FUNCTION_COUNT_BUDGET` via the gate's own AST walk over all of `tan/` Closes #543. Closes #531. Covers #544. Covers #545. Covers #425 -- these three overlap PR #548, which carries a subset of this change; the maintainer decides which supersedes. Co-authored-by: Caner Alp <contact@alplab.ai>
…#475) * test(surface): walk every tan command in order against a real project scripts/e2e-full.sh is a release regression harness -- hermetic $HOME, tree wiped every run, seven commands driven deeply to prove already-fixed bugs stay fixed. Measured 2026-08-05: those seven (--version, bootstrap, init, doctor, build, generate, examples) are its whole surface, out of the 32 `tan --help` lists. The other 25 had no end-to-end coverage at all. This adds the other axis -- every command, in dependency order, against a REAL project, repeatably, with no $HOME hijack. Neither replaces the other: the regression suite needs a hermetic home to mean anything, and this one needs the operator's actual machine to mean anything. scripts/tan-surface/run.sh --sdk-root <alp-sdk> # full walk scripts/tan-surface/run.sh --sdk-root <alp-sdk> --phase discovery # ~20s scripts/tan-surface/run.sh --project <my-app> --sdk-root <alp-sdk> # read-only Known defects are pinned to their issues with xstep/xstep_out: while the bug stands the run is green, and the day it is fixed the harness reports XPASS, exits non-zero, and names the entry to retire. Nobody has to remember to re-check by hand. That mechanism has already paid for itself. Written against 0.5.0, first run against 0.5.1 reported 8 XPASS -- #453, #454, #455, #456, #457, #469 and #470 all fixed -- and those are now positive assertions, so the fixed behaviour is what gets defended. Writing the harness also FOUND two of them: #470 (renode accepted --project and resolved the build root from the CWD) and #469 (the workspace-orphan refusal printed a stringified None). Two lessons are encoded in the comments because both cost a wrong result here: assert the MESSAGE and not only the exit code (under #470 two distinct renode refusals collapsed into the same CWD-lookup failure, so an exit-code check passed while measuring nothing), and never write an xstep_out pattern the fixed output can still match (#458's fix ADDED a line below the one the pattern matched, and grep is line-oriented, so the harness reported a false XFAIL). Safety, because a surface walk runs destructive commands: - `tan flash` is never run and there is no flag to enable it - --project is read-only unless --allow-mutate - bootstrap is opt-in and runs on a COPY of the SDK, because it MOVES the checkout (#185); ~/.alp/sdk-default is saved and restored - an already-bootstrapped workspace is detected rather than rebuilt - new-som is always given an explicit --output-root, since it defaults to writing into the SDK checkout Verified against the v0.5.1 release asset on macOS arm64 with alp-sdk v0.14.0: 86 pass, 0 fail, 1 xfail (#448, renode), 0 xpass, 0 skip -- including a real ARM build, a Renode boot, and the JSON envelope contract on nine commands. * fix(tan-surface): close CHANGES_REQUESTED -- data-loss guards, honest verdicts, re-derived expectations Closes every finding in the two alpCaner reviews on PR #475: Blockers: - run.sh: --work is deleted at exit ONLY when this run created it (a WORK_CREATED flag gates the trap), instead of unconditionally rm -rf'ing whatever path was passed -- a pre-existing --work directory (or $HOME) used to be destroyed. - build/diag: build --materialise, a real build, run, lock, and support-bundle are now gated on --allow-mutate exactly like generate/clean, closing the false "read-only against a real project" claim (they ran unconditionally before). Majors: - envelope(): also asserts envelope.exitCode against the REAL process exit code (#327), not only its own internal ok/exitCode consistency -- the producer-computed tautology could never fail on its own. - xstep/xstep_out: a timeout (RC 124) can no longer score XPASS -- "not the broken exit code" is not evidence a defect is fixed, and a hang is #448's most likely failure shape. - step_out_rc: new primitive asserting BOTH exit code and output pattern in ONE invocation, applied at every step_out call site the review measured scoring a false PASS on the wrong exit code (renode/run/debug-config), without doubling the cost of the multi-minute build/run/renode steps a separate step+step_out pair would need. - summary(): SKIP is now visible and, under a new --strict flag, fails the run -- "0 fail" no longer silently reads as "walked the whole surface". - cases.sh: an init that wrote no board.yaml now records a SKIP instead of a bare warn+return that dropped the whole project phase from the counts. - check_command_surface(): proves KNOWN_COMMANDS against tan --help's own output every run, in both directions, so a 33rd command lands as a loud FAIL instead of a silent gap in this hand-kept list. Re-derived against dev (16 commits, not assumed from 0.5.1): pinmux on an unknown --family exits 0 (a warning-only "I don't know", not 2 -- a different code path from a real-but-empty table); renode's no-build and multi-slice refusals both exit 1 (RUNTIME_FAILURE); run without --flash and debug-config --preview post-build both exit 0. swd_probe's new openocd_usb_location/pyocd_uid flags and build's new cores.<id>.app refusal (#523) are confirmed unreachable from this harness (flash is out of scope by design; every app: this harness scaffolds resolves to a real directory). Minors closed in the same pass: the harness now proves $TAN is actually tan (--version must print "tan <ver>") before running ~90 steps against it, and documents the identical Windows bash.exe/WSL-stub trap one level up; a missing timeout/gtimeout is now announced instead of silently making every --timeout inert; the faultdecode PRECISERR|BFARVALID alternation is split into two anchored assertions so losing BFARVALID cannot still pass; the debug-config pre-build pattern no longer also matches --help's own options table; the "run stops short of flashing" regex is the exact printed line. shellcheck -S warning and bash -n clean on all three scripts (both were already clean pre-change). python -m pytest tests -q: 3620 passed, 236 skipped, 18 xfailed, 0 failed (unchanged -- this PR touches no Python). Refs #448, #470 * fix(tan-surface): stop model build writing outside the sandbox, close the last review pass - diag phase: `model build` ran un-gated against `--project`. On a project whose board.yaml carries a `models:` block, model_cmd.py resolves a relative `--out` against the project root, not the harness's scratch dir (model_cmd.py:352-354) -- a real write into an operator's own --project tree with no --allow-mutate gate. Pin `--out` to an absolute path inside $WORK so the write always lands in scratch, regardless of mode or whether the board declares models. - envelope(): gained the same optional `--timeout N` every other primitive here already had. "build envelope" was silently re-running the real build under envelope()'s fixed 900s default where the plain `step "build"` right above it was given 1800 -- pinned to --timeout 1800 at that call site. - run.sh: the bootstrap probe checked only that `.west/config` + `.venv` exist beside --sdk-root, never that `[manifest] path` in that config actually names this checkout. An unrelated west workspace sitting next to the SDK checkout would match on presence alone and turn every workspace/build step into a confusing FAIL. Now reads the manifest path back and requires it to match. - README: declares the bash/coreutils/python3 host requirement and a Windows note (no native path -- WSL/Git Bash), and states the full-walk runtime (under a minute without a bootstrapped workspace; several minutes plus bootstrap's own 10+ once one exists) that was previously undeclared. - getting-started.yml: shellchecks scripts/tan-surface/*.sh the same way the existing step already does for install.sh -- nothing linted these before. shellcheck -S warning and bash -n clean on all three scripts (unchanged). python -m pytest tests -q (python/, py -3.12 venv): 3620 passed, 236 skipped, 18 xfailed, 0 failed -- unchanged from the prior commit, as expected: this touches no Python. Refs #448, #470 -- both remain open; neither is closed by this pass. * fix(tan-surface): gate model build on can_mutate against a real --project `model build` no longer hard-asserts exit 0 unconditionally against a real `--project`: a board.yaml's `models:` block is content the harness cannot predict, and a real project's models may name sources the harness's SDK/toolchain cannot compile. Hard-asserting success against that unknown shape is the same "reports a verdict it did not earn" failure mode the envelope/xstep fixes already closed. The pinned absolute `--out` (already landed) keeps the write itself confined to $WORK regardless of mode or the board's `models:` block; this closes the remaining half -- requiring --allow-mutate here matches every other step that runs real commands against the operator's own project, and SKIPs rather than FAILs without it, same as its `support-bundle` sibling right below. Verified against a real `dev`-built tan (0.5.2-rc1.dev0, pip install -e .) and a real alp-sdk checkout, not only shellcheck/bash -n: - sandbox mode (can_mutate always true): "model build" PASSes, unchanged. - `--project <real dir>` without `--allow-mutate`: "model build" now SKIPs ("real project without --allow-mutate"), matching support-bundle exactly. - Every phase reachable without a bootstrapped west workspace or renode (discovery, project, generate, workspace's pre-bootstrap steps, build's pre-bootstrap steps, diag, teardown) run clean: 124 pass, 0 fail, 4 skip (all expected -- no bootstrapped workspace, no renode on PATH). The bootstrap-dependent steps (quality/migrate/lock/kconfig, the real build/run/renode) were not re-run end-to-end here for the same reason the prior pass didn't: no Zephyr toolchain in this environment. shellcheck -S warning and bash -n clean on all three scripts. python -m pytest tests -q (python/, py -3.12 venv, dev merged in): 3623 passed, 236 skipped, 18 xfailed, 0 failed. Refs #448, #470 * fix(tan-surface): name the real cause in the workspace probe, stop earning verdicts the project owns The not-bootstrapped diagnostic stated a FALSE cause. Measured on a real host with --sdk-root /home/caner/alp-sdk: ~/.west/config carries `path = alp-sdk` and basename($SDK) IS alp-sdk, yet run.sh printed "found .west/config or .venv there, but manifest path=alp-sdk does not name this checkout". The manifest DOES name it; the missing piece was ~/.venv. The verdict was right and the reason was wrong, which sends the reader at west's manifest instead of the absent venv. The one elif is now four distinct arms, each printing its own cause: venv missing, manifest naming something else, .venv with no .west/config, neither present. `quality --profile quick` and `migrate --check` hard-asserted exit 0 against an operator's real --project with no gate. Neither writes, but both report a verdict on the PROJECT'S content, not on tan -- `tan migrate --help` documents --check verbatim as "Report version drift; nonzero on drift" -- so a drifted real project failed the harness spuriously. Gated on can_mutate, the same treatment the `model build` step already carries and for the same reason. The two refusal halves (exit 2) stay ungated: argument validation decides before either command reads the project. The #448 rationale said "slated for removal", contradicting the issue's current title ("emit a support-paused warning; retain the command, modules, fixtures and CI models") and the maintainer's 2026-08-04 reframe ("renode is PAUSED, not removed"). Corrected, so nobody deletes the case expecting the command to go. Two nits: a value-taking flag given with no value died on bash's own `$2: unbound variable` (exit 1, no usage) instead of the documented exit 2 -- now `ABORT: --sdk-root needs a value` plus usage, exit 2, measured; and a pre-existing --work accumulated every run's SDK copy, scratch trees and .out/.err captures forever, since "never delete what the operator gave us" had become "never clean up at all" -- an existing --work is now the PARENT of a per-run tan-surface.XXXXXX sandbox, which is what gets cleaned, while the directory passed in is still never removed. CHANGELOG entry added for the harness and its shellcheck CI step. Gates: python -m pytest tests -q -> 3620 passed, 249 skipped, 19 xfailed, 0 failed (275.23s). shellcheck -S warning scripts/tan-surface/*.sh rc=0; bash -n clean. Surface walk against a real alp-sdk: sandbox 72 pass / 0 fail / 2 skip; --project read-only 52 pass / 0 fail / 7 skip, with the project tree proven byte-identical (md5sum of every file) before and after. Refs #448 Refs #470 --------- Co-authored-by: Caner Alp <109098482+alpCaner@users.noreply.github.com>
…y knew (#578) * fix(cli): stop kconfig/model/run/image/size dropping what they already knew Eight defects across three issues, all the same shape: a fact the command had already computed never reached the user. - kconfig built `SdkInfo(sdk.path, sdk.tier)` from an `ActiveSdk` that carries `broken_project_pin`/`foreign_global_default_for` and read neither. `_fail` hardcoded a one-element issue list and the success emit passed a literal `[]`, so a run with a broken `.alp/sdk-path` answered `ok: true, issues: []` with a full symbol menu solved out of a checkout the pin does not name -- and this command is the alp-sdk-vscode `prj.conf` LSP's live feed. - model build was the only `resolve_project_context` caller that read none of the three resolution facts, dropping both warnings in JSON and text alike. Resolution moved to the caller so the warnings survive a `ModelError` too. - run computed the pair into `issues` but text mode prints `text_lines`, a separate list they never reached. - image and size computed the pair into `issues` and never into `text`, on both the error and the happy path. - image's `helper-missing` message said "no --sdk-root" even when `--sdk-root` WAS given and simply failed the loader-marker check. - image dropped every non-`ok` slice with no notice -- the only exclusion in `_assemble_bundle` that reported nothing -- while writing a `bundle-manifest.json` whose `boot_order` still named a core `slices[]` carried no artefact for. - size never read a slice's manifest `status`, so a slice recorded `failed` was measured from run 1's leftover `zephyr.elf` and reported as a current measurement, with `--fail-over-budget` computing its verdict from those stale bytes. - kconfig's second `except UnicodeDecodeError` was unreachable and raised a CONTRADICTORY issue code. Re-measured against the frozen oracle before deleting -- `tan 0.4.1` answers `kconfig.board-yaml-missing` for invalid UTF-8, so the live arm was right and the dead one was the pre-#421 explanation. Deleted, not reordered. A new AST gate makes the shape (ruff's B025) impossible to reintroduce without a ruff dependency CI does not install. The two #499 fixes are deliberate oracle divergences, measured on `target/debug/tan` first and recorded in `tests/parity/ test_image_size_oracle.py`'s divergence register. Both are narrowed to a DECLARED non-`ok` status, and size's only fires when a measurement was actually suppressed -- so each materialises exactly where its defect does and every frozen fixture stays byte-identical. Closes #440. Deliberately NOT closing the other two: this branch owns five modules, and both issues name defects outside them. #497 keeps defects 1, 7 and 8 (`sdk_cmd.py`, `examples_cmd.py`); #499 keeps defects 3-10 (`clean_cmd.py`, `support_bundle_cmd.py`, `inspect_cmd.py`, `core/size.py`, `core/image_bundle.py`, `core/system_manifest.py`; defect 7's `consent.py` already landed via #536). Covers #497 defects 2-6 and #499 defects 1-2. * fix(cli): stop the internal-failure catch-alls dropping the SDK-resolution pair The first pass at #497 defect 2/5 threaded `sdk_resolution_issues` into the refusal paths INSIDE each command's `_run`-style inner function and left the OUTER `<command>.internal-failure` catch-all -- which runs strictly after the ladder has already answered -- reporting the crash alone. kconfig: `_fail` has NINE call sites, not eight. Seven took the pair; the eighth, `kconfig.internal-failure`, did not. Reproduced against a broken `.alp/sdk-path` workspace with a discoverable sibling and `_resolve_zephyr_base` (outside every `try:` in `_run_kconfig`) raising OSError [Errno 24]: exit 5, `issues: [kconfig.internal-failure]`, `sdk.project-pin-unresolved` dropped. The ninth site is `kconfig.no-sdk-root`, which by construction has nothing to report. image, size: the same site, the same drop, in JSON and text alike. model, run: already clean -- both resolve in the OUTER function, so their handlers can read the pair directly. Pinned with tests so they stay that way. The three that could not resolve in the outer function (their ladder needs paths the inner function resolves) now pass an `SdkDisclosure` down and record into it the instant the facts exist. Recorded rather than recomputed in the handler: the resolver is itself one of the things that can raise, and that handler must not. --------- Co-authored-by: Caner Alp <contact@alplab.ai>
… Ctrl-C from lying on stdout (#580) * fix(envelope): stop a lone surrogate, a forwarded --format json and Ctrl-C from lying on stdout Three defects in the one serialisation/dispatch path, all of which end with stdout either empty or carrying an envelope that does not describe what happened -- and that envelope is alp-sdk-vscode's only channel. 1. A lone surrogate killed stdout AFTER `_serialise()` had already succeeded. `ensure_ascii=False` writes a surrogate straight through into the returned `str`, so the encode failed at `emit()`'s `print(text)` -- past the guard whose own comment says no payload may ever crash stdout. `tan inspect --format json` from a directory created as `proj\xffx` exited 1 with zero bytes on stdout and a Rich `UnicodeEncodeError` traceback. Every surrogate is now replaced with U+FFFD on the finished document, which is exactly what the frozen v0.4.1 oracle emits for the same directory (`Path::to_string_lossy`): the two `clean --format json` envelopes are now byte-for-byte identical, measured. Not an `ensure_ascii=True` fallback re-serialisation -- that escapes every other non-ASCII character in the same document too. 2. A `--format json` that is not tan's own flag flipped the whole run into JSON mode. `_wants_json` is Rust's parse-FAILURE-arm scan, and the port promoted it to a process-wide switch, so `tan quality -- --format json` answered `command:"cli"` / `cli.parse-error` in place of the real coded refusal and `tan lock -- --format json` reported a west child's exit as a parse error. Fixed by recording the PARSE OUTCOME (`_DispatchedCommand`, the missing half of Rust's `match Cli::try_parse()`) rather than by making the scan cleverer: `_wants_json`'s behaviour is unchanged. Two earlier attempts rewrote the scan and each reopened the defect a different way; a third textual shape is ruled out by measurement -- the oracle answers `quality -- --format json` in TEXT and `build -- --format json` in JSON, and the only thing separating them is whether the parser accepted the argv, which no scan of argv can know. This also settles the second vector, `tan --format json build --format text`. 3. Ctrl-C was reported as an invalid command line. Typer re-raises `KeyboardInterrupt` as `Exit(130)`, so an interrupted run was indistinguishable from any other non-zero exit with no envelope and fell into the `cli.parse-error` fallback -- at an `exitCode` of 130, outside the contract's fixed 0-5 set, for a run that was already spawning. Now the new `cli.interrupted` at `RuntimeFailure` (1), with the process exiting 1 to match. Fixed in `tan/cli.py`, not in any one command; text mode is untouched and still exits 130. `tan/cli.py`'s module-size pin is re-measured at 1008 (`wc -l`) and the function-count budget re-measured at 219 by the gate's own AST walk over all of `tan/` -- unchanged, since `main()` was already over the 50-line cap. Closes #546. Refs #491 -- deliberately NOT closing it. Its ten defects were split across concurrent lanes; this one owns `python/tan/cli.py` and the envelope serialisation path, so defects 1, 3 and 4 land here and defect 2 re-verified clean on current dev (tan-cli#488 already routed every stderr `isatty` probe through `tan.env.stderr_is_tty`). Defects 5-10 live in `init_cmd.py`, `monitor_cmd.py`, `core/build_plan.py` and `bootstrap_cmd.py` and are untouched here. * test(envelope): probe the filesystem for a non-UTF-8 name instead of guessing from os.name `test_a_surrogate_in_the_cwd_still_answers_one_envelope` was guarded by `skipif(os.name != "posix")` whose own `reason` named the real requirement correctly -- "needs a filesystem that accepts an arbitrary non-UTF-8 byte in a name" -- while testing the OS family instead. macOS IS `os.name == "posix"`, so the guard passed there and APFS (which validates filenames as UTF-8, unlike ext4, which stores raw bytes) refused the `mkdir`: OSError: [Errno 92] Illegal byte sequence: '/private/var/.../test_a_surrogate_in_the_cwd_st0/proj\udcffx' reddening `python -- pytest across python/ (macos-latest)` while ubuntu and windows stayed green. Same mistake class tan-cli#530 closed by making a check portable, and the one `tan/env.py`'s `stderr_is_tty()`/`stdin_is_tty()` exist to avoid: probe the capability, never infer it. The `non_utf8_project_dir` fixture now attempts the exact `mkdir` the test performs -- shared helper, so probe and test cannot drift onto different operations -- inside `tmp_path`, which is the filesystem the test will use (`/tmp`, `$HOME` and the repo can be three different mounts). It also reads the name back as bytes and refuses a filesystem that ACCEPTS the mkdir and quietly rewrites the byte, since that would let the case pass while exercising nothing. The skip names what refused and where, so a genuine skip is distinguishable from a silent hole. Nothing the test asserts is weakened: it still spawns a real `tan inspect --format json` at the process boundary, which is the only place `emit()`'s `print` runs and where the surrogate defect manifested. --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…ported (#494, #495) (#583) * fix(init,bootstrap): stop resolving and reporting against a workspace that was never chosen (#494, #495) Six defects across the two commands, each with a test that fails on dev. tan init (#494 defect 10) -- `Path.cwd()` was unguarded, so a cwd removed out from under the process turned the whole command into `init.internal-failure` / exit 5 with an empty envelope. Measured against the frozen oracle on identical argv in an identical removed cwd: it exits 0 with the full preview, and so does this port's own `tan clean`. Falls back to "." like `clean_cmd`/`presets_cmd` already do. tan bootstrap (#495 defects 2, 3, 4, 5, 7): * The Python floor was read from $ZEPHYR_BASE unconditionally, so a tree `_select_workspace` was about to DISCARD still set the enforced floor and its reported source -- attributing it to a `python.cmake` inside the tree the very next warning said was being ignored, and hard-refusing `bootstrap.python-too-old` before the envelope ever said the tree was being dropped, with a remedy naming the wrong fix. Now gated on `_zephyr_base_will_adopt`, which the run already computed 130 lines earlier. An ADOPTED tree still sets the floor. * The auto-relocation target's occupied check tested raw non-emptiness, so the `.venv` `rollback_relocation_after` deliberately leaves behind read as foreign content and refused the identical retry of the documented quickstart's first command. Now uses the module's own `parent_needs_workspace_guard` predicate. No `.west` exemption: see `_relocation_target_occupied`'s docstring for the two cuts of one that were tried and reverted. * `--print-env` keyed its stdout branch on the FLAG, so every refusal computed before the short-circuit was written to STDOUT with stderr empty -- the terminal showed nothing at rc=2 while `env.sh` received prose with parens and backticks. The oracle puts these on stderr, stdout empty. * `FORCE_GIT_LONG_PATHS_ENV` was merged with `dict.update`, claiming `GIT_CONFIG_KEY_0` and resetting `GIT_CONFIG_COUNT` to 1 -- deleting a corporate host's proxy override and stranding its mirror `insteadOf` past the reset count. Appended at index N instead. * `--print-env` rendered the venv activation hint from the HOST's bin-dir name, disagreeing with the same run's `Next steps:` block on a workspace whose `.venv` uses the other layout. * refactor(bootstrap): give --print-env's venv-layout resolution its own helper Keeps `_print_env_outcome` at its recorded 50-line span rather than growing the function-count ratchet by one, and puts the tan-cli#495 defect 7 rationale next to the resolver instead of inline in the short-circuit. * fix(init,bootstrap): stop scaffolding and reporting facts the tree cannot support (#494, #495) Six more defects, each with a test that fails on dev, plus a layering fix. tan init (#494 defects 1-5): * `--from-example` globbed every regular file, so an example someone had run `west build` in was copied wholesale into the customer's new project. Measured on `examples/v2n/v2n-brd-i2c-bringup`: 613 files on disk, 607 of them under `build/`, 6 tracked. The first binary among the 607 also aborted the run with `init.example-unreadable`, so the visible symptom was a hard failure and the silent one was a 613-file project. Pruned to alp-sdk's own five `.gitignore` patterns and no more -- an earlier cut also pruned `build-`, which is in none of them and would have dropped a hand-written `build-utils/`. Symlinks are now skipped on both the file and directory side, matching the oracle's `DirEntry::file_type()`: `Path.is_file()` FOLLOWS a link, so one pointing out of the example inlined that file's content into the new project. * A vendored tree is picked by FAMILY but only its `som: sku:` line was retargeted, so every SKU outside the two representative ones inherited that tree's core ids. `--som E1M-AEN301` wrote `a32_cluster` for an Ensemble E3 that has none -- `ok:true`, exit 0 -- and `tan validate` hard-errored `unknown core id ['a32_cluster']` on the next command. `retarget_board_yaml_ cores` renames the app entry to `app_core_for_sku` and drops the companion cluster; both edits only ever REMOVE wrong facts, since `tan init` is SDK-free and has no topology to consult. Byte-exact passthrough for the trees' own SKUs, so every fixture is untouched. * The vendored-tree guard fired only at ZERO files, so a partially delivered tree read as complete: five files written, `ok:true`, and a `CMakeLists.txt` still naming the `src/main.c` that was not there. The expected set is derived from the tree's own `target_sources`, so it tracks a template that gains a source with no edit here. * `_read_verbatim` re-raised a bare `UnicodeDecodeError` -- a byte offset and no path -- straight into `init.example-unreadable`. * `tan scaffold` wrote a module NO shipped template compiles and said nothing. Measured against the frozen `scaffold_trees.json`: not one of the six templates names `modules` in any `CMakeLists.txt`, and only `minimal-app` sets an include directory. The README now carries a `## Wiring` section with BOTH real build shapes -- the `app` target for five templates, `alp_app` via `ALP_APP_SOURCES` for `minimal-app`, whose paths are relative to `src/` and whose include directory is already set. tan bootstrap (#495 defects 1, 6, 8): * `find_workspace_venv`'s upward walk had no manifest guard, while `west_workspace_dir` rejects the same ancestor via its #307 guard -- so on a dirty host the `west` BINARY came from a foreign `zephyrproject/.venv` and its `cwd` from the correct workspace, and that interpreter is what tan bakes into every Zephyr slice as `-DPython3_EXECUTABLE`. Keyed on `.west/config`, not the `.west` DIRECTORY: tan writes its own `tan-workspace-sdk` record into `.west/`, and testing the directory took `tan doctor`'s `venvProvenance` check out of the report entirely. * `manualInstallHints.posix.note` was dropped at parse, at render AND in the fallback, so no Linux/macOS customer was told the Zephyr SDK is a separate `west sdk install`. Optional on the wire where `windows` is required, since every SDK before v0.14.0 declares `windows` alone. * `--workspace C:ws` returned `C:ws` UNJOINED -- a relative path out of a function whose contract is to absolutise one, `cwd` ignored -- because `ntpath_isabs`'s `^[A-Za-z]:` regex says True where `ntpath.isabs` says False. Refused as ambiguous at the call site, not in the predicate: its other caller, `is_plain_relative`, needs the opposite answer for `C:ws`. Layering: `manifest_points_at` and `same_directory` move down to `tan/core/bootstrap.py`. `tan/core/venv.py` was reaching UP into `tan.commands.bootstrap_cmd` four times behind deferred `# noqa: PLC0415` imports whose only job was to dodge the resulting cycle. Budgets re-measured on this tree with the gate's own AST walk, not summed: five functions crossed 50 lines (219 -> 224, each itemised) and four module entries move. `_FUNCTION_WORST_BUDGET` is NOT raised -- `_run` measured 704 against the 707 ceiling. --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…, and stop calling a real emit a failure (#576) * fix(validate,generate): make the two dead structural checks reachable, and stop calling a real emit a failure Five defects from tan-cli#498, all measured against the frozen v0.4.1 oracle and alp-sdk `99e47476` before and after. 1. `validate_board_text`'s two v2 structural checks were UNREACHABLE. The `_effective_schema_version(doc) >= 2` gate can never open -- alp-sdk pins `LATEST = 1` with an empty migration registry, `board.schema.json` calls an absent `schemaVersion` "version 1 permanently", and 0 of the 100 board.yaml files under `alp-sdk/examples` declare the key. Worse, the gate was wrong about the SDK: `board.schema.json` conditions nothing on `schemaVersion`, carrying `required: [som, cores]` and `not: {required: [os]}` at its root. Both checks are unconditional now, so `--offline` stops answering `clean`/exit 0 on boards the real validator refuses with 3 errors. The one conformance case this costs (`validate-offline-clean`, whose input has no `cores:` and which the SDK answers `error[ALP-B001]` on) is DECLARED in `DELIBERATE_DIVERGENCE` rather than re-recorded: the golden is the cross-language contract the frozen Rust binary is also held to. 2. Every rich `error[ALP-Bxxx]` block lost its code, its `= hint:` and its `= see:` page on the way through `parse_validator_stderr`. Findings are a `_Finding` record now, so the code, hint, documentation URI and the arrow's `line:col` plus the caret span reach `issues[].message` and reach diagnostic-v1/SARIF as the structured `code`, `hint`, `documentationUri`, zero-based `range` / one-based `region` and rule `helpUri` fields alp-sdk's own exporter emits. 3. A board.yaml `--offline` could not read (cp1252, a directory) exited 5 `validate.internal-failure` -- "tan crashed" -- while the same file on the spawn path exited 2. It is `validate.board-yaml-unreadable` at exit 2 now, with its own text verdict line, since nothing was validated. 4. `_missing_emit_output` treated a zero-byte artefact as proof of failure. `alp_project.py`'s unscoped per-core emits legitimately write 0 bytes and exit 0 when no core matches the mode's OS class, so the subprocess engine answered exit 3 about files that were on disk -- and under `--output` then unlinked them. The writability probe removes the file it creates, making plain existence honest evidence; `_output_stamp` keeps tan-cli#397's guarantee for a destination the emitter left untouched. 5. `--all --target <mode>` silently discarded the named target, and with `zephyr-board` let a `--core` past the guard whose own message says it does nothing for the `--all` set (measured: `alp.conf` 3799 -> 1867 bytes). The contradictory pair is refused with `generate.invalid-target` at exit 2. Frozen paths (`contract/envelopes/`, `python/tests/parity/oracle_fixtures/`, `crates/`) are byte-identical to origin/dev. Closes #498. * fix(generate): record the coarse-timestamp limit of the untouched-destination check `_output_stamp` compares (st_ino, st_size, st_mtime_ns) so an emitter that exits 0 having left an already-present destination exactly as it found it is still refused -- tan-cli#397's guarantee, kept through #498's widening. On a filesystem with coarse timestamps (FAT 2s, HFS+ 1s; not ext4/APFS/NTFS) an in-place rewrite of byte-identical content inside one tick is indistinguishable from no write, and is reported as a failed emit. That is the deliberate side to err on -- a false failure is loud and costs a re-run, while the false success it replaces fed a previous board's alp.conf into the next `tan build` silently -- so it is written down rather than left for a reader to rediscover. Module budget re-pinned by measurement: generate_cmd.py 1295 -> 1303. * test(gates): record the function-count re-walk after the rebase onto dev The rebase onto #582's dev landed the budget on a tree neither side had measured. Re-walked with the gate's own AST walk over all of `tan/` including `tan/planner/`: 225, of which 49 crossings are in `tan/planner/`. Set-differenced against dev's 221, the four new crossings are this change's own -- `validate_board_text` 89 (not the 82 the pre-rebase note recorded), `resolve_targets` 75, `_missing_emit_output` 75, `_ensure_writable` 60 -- and nothing dropped below the cap. Every `_MODULE_BUDGET` entry re-measured clean, and the worst function is unchanged at `bootstrap_cmd.py:_run` 701. * gate: re-walk the function count on the merged tree (230) --------- Co-authored-by: Caner Alp <contact@alplab.ai>
… tee a pty (#575) * fix(flash): stop swd_probe claiming an unobserved flash, and give the tee a pty Two intent-vs-observed defects on the write path, both in flash_cmd.py. tan-cli#540 -- swd_probe's J-Link arm asserted `{device} flashed via J-Link @ {base}` on JLinkExe's exit code ALONE, on the one backend whose Commander script carries no `verifybin` to fall back on. #522 measured on real E1M-AEN801 silicon that a halt failure does not move that exit code even with `-ExitOnError 1` (which this arm passes), so a load into a core that never halted reported a clean flash with nothing having checked the bytes landed. The entry message is now qualified from the transcript -- `write attempted via J-Link ...; the core did not halt (J-Link reported "...") and this backend runs no verifybin` -- and a new `flash.swd-probe-write-unconfirmed` warning carries the same fact to `issues` and to default text output. Status stays `ok` with rc 0: there is no bench evidence a GD32 halt failure means a failed write, and turning a possibly-fine flash into a hard failure would trade an overstatement for a false negative. tan-cli#541 -- the `_Tee` #522 needs handed the child `subprocess.PIPE` for both streams, so the child stopped seeing a tty and pyocd/west/openocd dropped the \r-redrawn progress bar an operator watches through a multi-minute write. `_spawn`'s live-console branch now tees through a pty when the console is a real terminal, so the child keeps its `isatty()` and the transcript is still captured. Pipes are kept unchanged on Windows (no `pty` module and no stdlib ConPTY), on a non-terminal sink (piped to a file, or CI -- where the pipe path is the correct one), and on a host that cannot allocate a pty. A pty is one device, so the whole transcript lands in `_Outcome.stdout`; both downstream consumers already read it that way. * fix(flash): read swd_probe's halt markers positionally, and keep the pty out of the diagnostic Review follow-up on tan-cli#540/#541, both defects measured before and after. MAJOR 1 (#540): the detector reported a SUCCESSFUL write as unconfirmed. `_swd_probe_halt_markers` did a positionless substring search over the whole transcript, but `jlink_commander_script` emits TWO halt-capable stages -- the pre-load `r`/`halt` and, after the load, `r`/`g`, which is ON BY DEFAULT (`do_reset = _default(fa_bool_checked(fa, "reset"), True)`). A resident image such as the GD32 bridge firmware starts the instant `loadbin` finishes, so that second `r` cannot halt it and JLinkExe prints `Failed to halt CPU` / `CPU is not halted` at exit 0 on a write that landed. tan then told the operator to redo it on hardware and raised `flash.swd-probe-write-unconfirmed`, which alp-sdk-vscode renders as a warning. The transcript ordering was established by RUNNING the Commander script through a capturing stub on PATH, not assumed: the load is reported finishing (`Downloading file [...]` then `O.K.`) before any of the reset chatter. Only markers BEFORE that point can speak to whether the write happened; markers after it speak to the reset, which is all Flow D ever claims them for. When the tool never reports a load COMPLETING -- the pre-load halt failed, the wording differs, or `_Tee`'s bounded join truncated it -- there is no boundary, every marker counts, and the unconfirmed verdict stands. Covers `loadfile` (ELF/HEX) as well as `loadbin`, which is the arm that keeps living on the transcript alone. MAJOR 2 (#541): the pty merge was degrading the customer-visible diagnostic, and the PR body called it harmless. `_capture_tail` feeds the text-mode `FAIL:` line and `data.entries[].message` on the pty path too, and `str.splitlines()` splits on `\r`, so a `\r`-redrawn progress bar -- which the child now correctly draws, because #541 gave it a tty back -- took three of the tail's four slots and pushed the real diagnosis out, while raw `\x1b[31m` shipped verbatim inside a customer-visible string. `_capture_tail` now reads the lines a terminal would be showing (`_console_lines`): split on `\n` alone, keep the segment after the last `\r`, strip CSI/OSC escapes. Measured, same child, driven from a process whose stderr is a real pty: before 'swd_probe[gd32_bridge]: [40%] writing image | [70%] writing image | [100%] writing image | Error: could not connect to target' after 'swd_probe[gd32_bridge]: [100%] writing image | Error: could not connect to target' The lost stderr-first preference is addressed in `_capture_tail`'s own docstring rather than reversed: a pty is ONE device, so there is no second stream to prefer, and giving the child two ptys would hand it two different terminals and would cost the #540 marker search above the chronological ordering its positional reading depends on. The pipe path is untouched. Minors: adds the missing CHANGELOG entries, and makes the suite honest under `pytest -s`. Three tests asserted the pipe path's stream split while reading the ambient `sys.stderr`, so `-s` on a terminal took the pty path and failed them; two now assert the transport-agnostic `stdout + stderr` their consumers actually read, and the one that genuinely needs a non-terminal stderr skips with the reason named. `_open_console_pty`'s own decision is asserted against a file this test owns rather than against however pytest was invoked. Budgets re-walked with the gate's own AST walk over all of `tan/` INCLUDING `tan/planner/` on the final rebased tree, never by arithmetic: `_FUNCTION_COUNT_BUDGET` 221 -> 224 (three crossings, all docstring: two of them functions that already existed and grew), `flash_cmd.py` 2953 -> 3124. Refs #540. Refs #541. * fix(flash): read a CRLF line ending as a line ending, not a progress redraw The Windows CI leg of this PR (`python -- pytest across python/ (windows-latest)`) failed `test_execute_message_text_mode_now_surfaces_a_real_spawn_diagnosis` with `'swd_probe[e1]: exited rc=3'` where the diagnosis belonged. The transcript was captured -- that test's first assertion, on `outcome.stdout + outcome.stderr`, passed -- so what broke is `_console_lines`, the helper this PR added for #541's MAJOR 2. It splits on `\n` alone and then keeps only the segment after the LAST `\r`, which is right for a `\r`-redrawn progress bar and wrong for the `\r\n` a Windows child writes. After the `\n` split every row ends in `\r`, the segment after that final `\r` is the empty string, every row fails the blank test, and the tail is empty. Measured on the shipped source, byte sequences run through the function directly: _console_lines('Error: could not connect to target\r\n') -> [] _console_lines('[40%] x\r[70%] x\r[100%] x\r\n Error: y\r\n') -> [] _console_lines('Error: could not connect to target\r') -> [] This is not a test-only defect. `_Tee` reads the raw pipe and decodes it itself rather than letting `text=True` translate, precisely so the live console stays live -- so on Windows the `\r\n` reaches `_console_lines` intact and every flash failure diagnostic blanks to a bare rc. That is the exact surface #541's MAJOR 2 exists to protect, for every Windows operator. The distinction: `\r\n` is a LINE ENDING; a bare `\r` not followed by `\n` is a redraw. The terminator's carriage return is stripped first, per `\n`-delimited segment, and only what remains is read for redraws. `rstrip("\r")` rather than removing exactly one, because a redraw is only observable when content FOLLOWS the `\r` -- so a trailing one drew nothing and can erase nothing, which also settles the last segment (no `\n` after it, hence no terminator): a transcript that ends mid-row keeps the row the terminal is still showing, rather than discarding the diagnosis of the run that failed. Measured after: 'Error: could not connect to target\r\n' -> ['Error: could not ...'] '[40%] x\r[70%] x\r[100%] x\r\n Error: y\r\n' -> ['[100%] x', ' Error: y'] 'Error: could not connect to target\r' -> ['Error: could not ...'] '[40%] x\r[70%] x\r' -> ['[70%] x'] The LF-only redraw is unchanged, asserted as a negative control end to end: this PR's own measured `'swd_probe[gd32_bridge]: [100%] writing image | Error: could not connect to target'` still holds, so the CRLF repair was not bought by disabling the collapse. Tests drive both ways (4 fail before, 5 pass after; the 5th is that negative control, which passes on both). They feed the byte sequences explicitly rather than spawning a child, because this box is Linux and the host's line-ending translation would make them vacuous -- `_console_lines` is pure, so the same code path runs either way, and the Windows CI leg is the end-to-end confirmation. Swept the rest of this PR's diff for the same assumption. The only other transcript readers are `_swd_probe_halt_markers`, `_jlink_load_completed_at` and `_flow_d_reset_qualified_message`, all substring `find`/`in` over `stdout + stderr` with markers that contain no line terminator (`Failed to halt CPU`, `CPU is not halted`, `Downloading file`, `O.K.`). Verified by running both endings through them: identical verdicts, the indices differing only by the extra `\r` bytes, which the comparisons are relative to. Budgets re-walked with the gate's own AST walk over all of `tan/` INCLUDING `tan/planner/`, both ways, never by arithmetic: 224 before, 225 after, the one new crossing being `_console_lines` itself going 35 -> 63 on two lines of body and 26 of docstring. `flash_cmd.py` 3124 -> 3152 by `wc -l`. Refs #540. Refs #541. * gate: re-walk the function count on the merged tree (230) * gate: re-walk the function count on the merged tree --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…stituted app dir (#577) * wip: toolchain-root resolver + app-dir substitution notice * fix(build): resolve ${TOOLCHAIN_ROOT} on the host, and announce a substituted app dir * gate: re-derive the module-size and long-function pins by measurement * docs: changelog for the toolchain-root resolver and the app-dir substitution notice * style: drop a stray blank line and re-pin build_cmd at its measured 1908 * test: assert the verbatim ZEPHYR_SDK_INSTALL_DIR spelling, not its posix form * gate: re-walk the function count on the merged tree (227) * gate: re-walk the function count on the merged tree * gate: re-walk the function count on the merged tree --------- Co-authored-by: Caner Alp <contact@alplab.ai>
* feat(contract): publish a doctor family in envelope-contract.json (#664) doctor's `data` values are host facts (installed tool versions, absolute paths, which checks even apply on this machine), so unlike the other 17 families it cannot be a byte golden. contract/doctor-data-keys.json is the new single source for the required `data` KEY SET only; the release workflow's "Bundle the envelope contract" step folds it into envelope-contract.json's envelopes.doctor as `dataKeys` (never `envelope`/ `exitCode`, which don't apply to a non-deterministic command). Enumerated 2026-08-12 by reading the one place doctor's envelope is assembled -- python/tan/commands/doctor_cmd.py's doctor() (the `data = {...}` literal), Check.as_dict(), summarise(), next_steps(), and tan.core.bootstrap.reported_missing -- then cross-checked against a real `tan doctor --format json` run (13 checks, exit 4 on this host). Re-run the same reading to re-enumerate. `scope` is included even though the issue's own worked example predates it (tan-cli#549 added it after); `status` and `scope` are typed as free strings, not enums, per the issue's own reasoning that a value added later must survive the trip. Added python/tests/conformance/test_doctor_contract_key_set.py so the published file cannot drift silently from what the command emits: it spawns a real `tan doctor --format json` and fails on either an emitted key nobody declared or a declared key the command stopped emitting. Verified it actually catches drift by deleting a declared key and re-running (failed with the expected diff), then restored and re-ran (passed). Left open, not decided here: whether `data.nextSteps` being identical to the ordered list of non-null `checks[].fix` values is guaranteed or incidental (the issue's own open question) -- noted in contract/README.md rather than resolved. * fix(contract): close the doctor key-set lockstep gap and false claims (#664) Three review findings against be3e394 (feat(contract): publish a doctor family in envelope-contract.json), all re-measured before fixing: 1. The lockstep gate only half-existed. test_doctor_contract_key_set.py checked checks[]'s keys against hardcoded _CHECK_REQUIRED_KEYS/ _CHECK_OPTIONAL_KEYS frozensets, never against the published file's own declaration -- so contract/doctor-data-keys.json's checks entry could be corrupted in either direction (a declared key deleted, an unemitted key added) with the test still green. Reproduced: deleted `scope`+`detail` from checks.requiredKeys and added a never-emitted key -- 1 passed. Restructured checks into {requiredKeys, optionalKeys} and missingPrerequisites into {nullable, items}, and now derive _CHECK_REQUIRED_KEYS/_CHECK_OPTIONAL_KEYS/_MISSING_PREREQ_KEYS from the JSON file itself. Re-ran the same corruption: now fails with the exact diff. Restored, re-ran clean: 1 passed. 2. contract/README.md's claim that "on every run measured so far, data.nextSteps has been byte-identical to the ordered list of non-null checks[].fix values" is false on the very run be3e394 cites: measured via the test's own _run_doctor() helper, this host emits 13 checks / 8 non-null fix values / 7 nextSteps entries (two checks share the fix `tan bootstrap`, collapsed by next_steps()'s dedup). Replaced with the real, deterministic rule -- nextSteps is the DISTINCT fix values of non-pass/ non-unknown checks, in check order, and is NOT a 1:1 index into checks[] -- in contract/README.md, contract/doctor-data-keys.json's _comment, and changelog.d/664.added.md. 3. Two of five published dataKeys values were English prose, not type tokens: missingPrerequisites was a full sentence, and checks[].fix's optionality was expressed as a parenthetical inside a type string. Since this contract has not shipped yet, restructured both to be pure structural tokens: checks is {requiredKeys, optionalKeys} (each a map of key -> type token) and missingPrerequisites is {nullable: true, items: {tool, command}}. A consumer now validates the whole shape without parsing English. Also, tied to the same file: - Drove the spawned `tan doctor` invocation from DOCTOR_DATA_KEYS["args"] instead of a second hardcoded argv, so a typo/rename in the published `args` fails the test that's supposed to catch it. - Dropped the unused `family`/`schemaVersion` keys from doctor-data-keys.json -- nothing reads either (release.yml keys off the literal "doctor" map entry; the test reads only `dataKeys`). - Fixed the stale ".github/workflows/release.yml" and "docs/release-contract.md" header prose that still described the release asset as "one golden envelope per command family" -- no longer true for `doctor`. - Moved the doctor-family entry out of CHANGELOG.md's Unreleased section and into changelog.d/664.added.md, per this repo's own changelog.d/README.md convention (already merged to dev via #676 before be3e394 landed, but be3e394 edited CHANGELOG.md directly). Verified locally: re-ran the release workflow's "Bundle the envelope contract" Python heredoc verbatim against the changed files -- bundles 18 envelopes / 313 issue codes, doctor entry is exactly {args, dataKeys}, no _comment/family leak. python -m pytest tests -q from python/: 4078 passed, 284 skipped, 1 xfailed, 0 failed. * fix(contract): close the remaining doctor lockstep gap for optional keys (#664) Round-5 review of 2bd70bd found the lockstep gate was still one-directional: adding a bogus key to contract/doctor-data-keys.json's checks.optionalKeys alone, or renaming missingPrerequisites.items.tool, both passed unnoticed (1 passed in each case), because a normal test host either never emits `fix` on every check or reports missingPrerequisites: null, so the per-entry assertions never ran. Reproduced both holes exactly as described before fixing. test_doctor_contract_key_set.py now spawns a SECOND, deterministic `tan doctor --format json` with PATH stripped entirely, so every tool the built-in fallback manifest requires (git/cmake/python3/ninja on POSIX, git/cmake/python/ninja on Windows) is unconditionally reported missing: hostPrerequisites always fails with a `fix`, and data.missingPrerequisites is always a non-empty list, regardless of what the running host actually has installed. That run lets the test assert every declared optionalKeys member was actually observed on at least one checks[] entry, and validate missingPrerequisites[]'s item key set against real, populated data instead of only ever matching vacuously against None. Re-measured both fixed holes: adding a bogus checks.optionalKeys entry now fails (`unobserved_optional`); renaming missingPrerequisites.items.tool now fails (`entry key set ... != declared ... items`). Reverted each mutation and confirmed the file is restored (sha256 unchanged) and the test green. This also closes the round's second major: contract/README.md's claim that the gate "fails ... in EITHER direction, at every level" (mirrored in changelog.d/664.added.md and doctor-data-keys.json's own _comment) was false against the prior, one-directional gate. It is not rewritten here because closing the gate above makes the existing claim true rather than needing to be narrowed -- every key set the published contract declares (top-level dataKeys, summary, checks required+optional, missingPrerequisites.items) is now checked in both directions by a real run. No production code changed; contract/doctor-data-keys.json is byte-identical before and after (verified by sha256). Covering lanes, from python/: `pytest tests/gates tests/conformance tests/commands/test_doctor_command.py -q` -> 650 passed, 18 skipped, 1 xfailed (was 649 before this commit's one new test). * fix(contract): close the 7 remaining minors from round-5 review of #664 Re-verified both round-5 majors are already closed on this branch (HEAD 374fe3e): reproduced the checks.optionalKeys and missingPrerequisites.items holes from be3e394/2bd70bd, then confirmed both now fail (`unobserved_optional` / entry key-set mismatch) with the forced-missing second run 374fe3e added. Nothing further needed there. Closed the 7 minors that survived: - contract/README.md's self-contradiction: :337-340 still said the release workflow folds doctor-data-keys.json into `envelopes.doctor` "verbatim", while :240-243 (written by 2bd70bd) already said the opposite. Reworded :337-340 to match. - contract/README.md's :240-243 comparison to `issue-codes.json` was itself wrong: `issue-codes.json` is not folded whole-file verbatim either -- release.yml:644 reads only its `issueCodes` array, dropping that file's own `schemaVersion`/`_comment`. Reworded to state both sources are folded partially. - doctor-data-keys.json's restructure (2bd70bd) lost the arrayness signal the old `"checks": [{...}]` notation carried, and left three incompatible notations (literal field map / meta-descriptor / array literal) with nothing distinguishing them. Documented all three explicitly in the file's own `_comment`. - test_doctor_contract_key_set.py hardcoded `== declared_summary == {"pass", "warn", "fail"}`, contradicting its own docstring's "never hardcoded here". Dropped the literal; the separate pin already lives in test_doctor_command.py:: test_unknown_is_counted_in_no_summary_bucket. Proved: adding a summary.unknown bucket still fails, now on the derived-set assertion. - `missingPrerequisites.nullable: true` was a published token nothing read (measured: flipping it to false still passed). Added an assertion that requires `nullable: true` on any run where the command actually emits `null`. Proved: flipping it to false now fails. - doctor-data-keys.json dropped `schemaVersion` while issue-codes.json keeps one, with no explanation for the asymmetry. Documented why in the `_comment` (neither is read by anything today; issue-codes.json's own copy exists for symmetry with the bundle's `schemaVersion`, this file's args/dataKeys need no independent one). - Re-checked README:137/:431's "fails on either an undeclared emitted key or a declared key the command stopped emitting" against the now-closed majors: the claim is true today (both directions are enforced), so left as-is rather than narrowing a now-accurate statement. No change to the published `dataKeys` schema itself -- every edit here is either `_comment`-only (doctor-data-keys.json) or test/doc prose. Verified by diff: the only lines touching `"dataKeys": {...}` are additions inside `_comment`. Covering lane, from python/: `pytest tests/gates tests/conformance tests/commands/test_doctor_command.py -q` -> 650 passed, 18 skipped, 1 xfailed (unchanged from pre-edit baseline -- no test added, removed, or newly skipped). * fix(gates): resolve the ledger conflict marker inherited from dev `python/tests/gates/MODULE_SIZE_BUDGET_LOG.md` arrived on this branch already carrying `<<<<<<< HEAD` / `=======` / `>>>>>>> origin/dev` at lines 53-65. The markers are not this branch's: they are on `dev` itself, landed by 3bd08fd (the squash of PR #702), and this branch inherited them when it merged dev. tan-cli#703 / PR #704 fixes them at the source and adds `tests/gates/test_no_conflict_markers.py`. This commit applies the same resolution here so the branch is clean whichever lands first. Both sides are real entries in an append-only ledger and both are kept, dev's first. Refs #703 * ci: suppress cache-poisoning on the step, not by line number The `zizmor · workflow security` gate went red on this branch with one HIGH finding -- `cache-poisoning` on `actions/setup-node` in release.yml's npm-shim publishing job -- without this branch changing a single line of that workflow. `.github/zizmor.yml` baselined the finding as `release.yml:850:9`. This branch added 14 lines of comments earlier in the file, which moved the step to line 864, and the anchor stopped matching. The suppression did not become wrong; it became misaddressed. That is the second time. The removed comment block recorded the first: `790 -> 850 (tan-cli#500)`, when a step added to the `build` job pushed this one down. An anchor that has to be re-derived every time an unrelated line count changes is not a baseline, it is a recurring tax -- and the failure mode is worse than the noise, because a stale line number can come to rest on a DIFFERENT step and silently suppress a real finding there. Moved to an inline `# zizmor: ignore[cache-poisoning]` on the `- uses:` line, which travels with the step. The rationale that lived in the config -- why it is baselined, and the two changes that would invalidate it -- moved with it, so the argument sits where the next person to edit that step will read it. Note for anyone repeating this: the inline comment must be on the `- uses:` line itself. Placing it on the line above does not suppress; that was measured, not assumed. Verified with zizmor 1.29.0, the version ci.yml pins: - with the suppression: rc=0, "No findings to report. Good job! (12 ignored, 49 suppressed)" - suppression removed: rc=14 -- the gate still fires, so this is a relocated suppression and not a disabled check. `.github/zizmor.yml` still carries one line-anchored entry, `artipacked: planner-resync.yml:140:9`, with the same fragility. It is left alone deliberately: this branch does not touch that workflow, so its anchor is currently correct, and converting it here would put an unrelated workflow into a PR about the doctor family envelope contract. --------- Co-authored-by: Caner Alp <contact@alplab.ai>
… the spawned interpreter (#665) (#708) * test: prove the tan_under_test hijack refusal actually fires (#665) tests/conftest.py's tan_under_test fixture (tan-cli#423) already refuses to run against a `tan` resolved from outside this repo's python/, but nothing exercised the refusal path itself -- every existing consumer only ever hits its silent-pass branch, so a green suite said nothing about whether the refusal would fire the day it matters. tests/gates/test_tan_under_test_guard.py plants a decoy tan package outside python/, puts it ahead of the real one on sys.path (the same externally observable shape a hijacked editable install produces), and proves the fixture refuses loudly with its own named message -- and stays silent for this repo's own correctly-resolved tan. Also documents the hazard in README.md's Development section: install into a venv you create, never a bare or --user `pip install -e ./python`. * test: probe the interpreter the suite spawns, not just this process (#665) tan_under_test asserted only on this process's `import tan`. The spawned children run `[sys.executable, "-m", "tan", ...]`; `tan/__init__.py` is empty, so `import tan` alone proves nothing about that chain. PYTHONPATH=$PWD bare-venv/bin/python -m tan --version -> exit 1 ModuleNotFoundError: No module named 'typer' PYTHONPATH=$PWD bare-venv/bin/python -c "import tan" -> exit 0 --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…K_COMMIT (#691) (#712) Nothing said a word when a locally-bound ALP_SDK_ROOT was not the commit tan's own pins declare, so a run against the wrong alp-sdk tree produced real-looking failures that were neither pre-existing nor caused by the branch under test. Measured on the same node IDs against an unmodified origin/dev, varying only ALP_SDK_ROOT: ALP_SDK_ROOT=a317330595f744d35f4d785869517110f3678f70 -> 30 passed ALP_SDK_ROOT=c07254b2589406acb3fcb5556bf1e995395431e3 -> 5 failed, 25 passed A full suite reported 9 failed, 4986 passed and it took a three-way comparison by hand to establish that five of the nine were the bound tree. python/tests/conftest.py now compares the bound checkout's HEAD to PINNED_SDK_COMMIT once per session, naming the variable that bound it, both SHAs and the direction and distance between them, and compares PINNED_SDK_COMMIT against parity.yml's PINNED_SDK_TAG in the same check -- the pair ci.yml says "MUST be bumped together", which has drifted twice. It WARNS, never fails: binding a newer tree deliberately is how the next planner re-sync's workload is discovered, and the defect was that it was silent. It stays silent when nothing is bound (ci.yml's python job, a bare pytest tests/), when the bound root is not a git checkout, and when a non-git tree merely sits nested inside some other repository -- git -C would otherwise answer with the enclosing repo's HEAD. tests/gates/test_sdk_pin_disagreement_warning.py builds real git checkouts in a tmp dir and proves both arms, both emission points and the live pins' readability -- a warn-only check has no CI status of its own, so nothing else can tell "it warned" from "it was silent". Co-authored-by: Caner Alp <contact@alplab.ai>
…ry stage (#573) (#711) * fix(planner): make load_board_yaml's metadata_root override reach every stage (#573) `load_board_yaml(path, metadata_root=...)` is documented as the tree the load resolves against, and two callers pass it: `tan/core/doctor_libraries.py`'s `_resolve` and `tests/core/test_sdk_revision_gate.py`. Stages 1-3 honoured it. Stages 4 and 5 did not -- they read the module-level bound `paths.METADATA_ROOT` instead: loader.py _resolve_storage -> _known_flash_devices(..., METADATA_ROOT) loader.py _validate_cross_fields -> resolve_memory_map(..., METADATA_ROOT) The mismatch was PARTIAL, which is what hid it. `som_preset` itself still came from the caller's tree, so an alternate tree's explicit `memory_map:` override and its `on_module.ospi_memories:` keys WERE honoured; only the SoC-JSON-derived branch of `resolve_memory_map` read the wrong tree. So a `storage[].flash_device` naming an SRAM bank the requested tree's SoC JSON declares was refused -- and the message blamed the customer's board.yaml while listing the OTHER tree's devices: board.yaml `storage[userdata].flash_device: sram6x` does not resolve to any flash device on SoM E1M-AEN301. Known devices: [..., 'sram6', ...] Fixing only those two lines is not enough, and that is the wider half of this change. The resolvers the loader hands the project to read the bound root too -- `partition.resolve_storage_partitions` (`_resolve_flash_device`, `_reserved_spans`), `carveout.resolve_carve_outs` (`resolve_memory_map`), and `kconfig`'s three `resolve_capabilities` calls -- and `BoardProject` carried no root at all, so a loader-only fix makes the loader ACCEPT a flash device the resolver then BLOCKS (status='blocked', "flash device 'sram6x' is neither a memory_map region nor an on_module.ospi_memories key"). The root therefore travels on `BoardProject.metadata_root`, and those five call sites read it back through `BoardProject.effective_metadata_root()`. `metadata_root` defaults to `None` on the dataclass rather than to `paths.METADATA_ROOT`, and `effective_metadata_root()` imports `paths` lazily: `paths` evaluates `REPO = sdk_root()` at module scope and raises when no SDK root is bound, and `models` is deliberately a bound-root-free leaf that `tests/core/test_sdk_revision_gate.py` loads with no SDK at all. A hand-built `BoardProject` therefore still falls back to the bound root, unchanged. Latent, not live: every production load takes the default, and the one caller that passes the parameter (`doctor_libraries._resolve`) binds the same root it passes, so its two roots agree. No behaviour changes on a default load -- the recorded root IS `paths.METADATA_ROOT` there. Not in scope, and still bound-root-only: the library-manifest readers (`kconfig._library_alias_table`, `kconfig._per_core_library_kconfig`, `libraries.py`, `project_emit/west_libs.py`, `validate._CURATED_LIBRARIES`), none of which have a project in hand at the point they read. The same two lines exist upstream in alp-sdk `scripts/alp_orchestrate/loader.py`; that side is untouched here. `python/tests/gates/module_size_budget.generated.json` re-measured via `scripts/regen_module_size_budget.py --reason ...` for loader.py's +3 lines. * docs(changelog): fragment for the metadata_root override fix (#573) --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…ver it (#689) (#713) * ci(getting-started): retry west sdk install, the GH_TOKEN does not cover it The step already carries GH_TOKEN to survive the GitHub API rate limit on fetch_releases. That is only the first network call it makes. west sdk install then runs setup.sh -t arm-zephyr-eabi, which fetches the GNU toolchain as a separate download no token affects: Installing 'arm-zephyr-eabi' GNU toolchain ... ERROR: GNU toolchain download failed FATAL ERROR: command ".../setup.sh -t arm-zephyr-eabi -h" failed Observed 2026-08-12 on PR #688, whose diff touches only python/tan/planner/** and two pins. The 70.7 MB SDK archive completed in the same step at 219 MB/s immediately before, so the runner's network was neither slow nor blocked. Three attempts with backoff, failing loudly on the third -- not a skip and not an `|| true`, because this step tests the remedy tan itself prints and a genuinely unavailable network must still red the job. Same policy clean-host.yml already reasoned out for `sdk list --online`: a gate that goes red for a reason unrelated to its subject stops being read. Closes #689. * ci(getting-started): retry west sdk install, the GH_TOKEN does not cover it The step already carries GH_TOKEN to survive the GitHub API rate limit on fetch_releases. That is only the first network call it makes. west sdk install then runs setup.sh -t arm-zephyr-eabi, which fetches the GNU toolchain as a separate download no token affects: Installing 'arm-zephyr-eabi' GNU toolchain ... ERROR: GNU toolchain download failed FATAL ERROR: command ".../setup.sh -t arm-zephyr-eabi -h" failed Observed 2026-08-12 on PR #688, whose diff is confined to python/tan/planner/{kconfig,libraries,validate}.py, the ci.yml and parity.yml SDK-ref pins, and test_planner_relocation_freshness.py's matching hash re-pin (`gh pr view 688 --repo alplabai/tan-cli --json files`) -- none of that reaches a toolchain download. Three attempts with backoff, failing loudly on the third -- not a skip and not an `|| true`, because this step tests the remedy tan itself prints and a genuinely unavailable network must still red the job. Same policy clean-host.yml already reasoned out for `sdk list --online`: a gate that goes red for a reason unrelated to its subject stops being read. Closes #689. * test(ci): pin the west sdk install retry loop so it cannot regress silently getting-started.yml's install step (tan-cli#689) now retries `west sdk install` 3x with backoff, but nothing proved the shape stays that way. This gate reads getting-started.yml's "install the Zephyr SDK" step directly and fails if the retry loop, the third-attempt hard exit, or the invocation itself (-t arm-zephyr-eabi) is edited away back to the bare one-shot call #689 was filed against. test_the_invocation_still_carries_the_flag_under_test does not count with the loop-membership check's `_INVOCATION` regex (`west\s+sdk\s+install\b.*-t\s+arm-zephyr-eabi`, re.S): with re.S its greedy `.*` swallows two duplicated invocations into a single match, so `len(hits) == 1` would still hold on a duplicate -- verified: constructing a loop body with the invocation copy-pasted twice, that regex still reports exactly 1 hit. It uses `_EXACT_INVOCATION` instead, anchored end to end from `--version` through the `--personal-access-token` continuation line, so a duplicate cannot be absorbed into one match and a mangled flag breaks the anchored shape instead of fuzzily matching anyway. Verified anti-vacuously, three defects injected into getting-started.yml in turn and reverted after each (`python3.12 -m pytest python/tests/gates/test_getting_started_west_sdk_install_retries.py -q`): * dropped (`-t arm-zephyr-eabi` removed from the invocation): 2 failed, 2 passed -- test_west_sdk_install_is_wrapped_in_a_retry_loop and test_the_invocation_still_carries_the_flag_under_test both fail. * duplicated (the `if west sdk install ...; then break; fi` block copy-pasted immediately after itself): 1 failed, 3 passed -- test_the_invocation_still_carries_the_flag_under_test fails on `assert 2 == 1`. * mangled (`arm-zephyr-eabi` -> `arm-zephyr-eab1` on the invocation line): 2 failed, 2 passed -- the same two tests as the dropped case. Restoring the unmodified step made all four tests pass again after each of the three injections. The other two tests (retry-loop shape, third-attempt hard exit) were also re-verified against this branch's parent commit e01d8be's step body (the pre-fix, bare one-shot invocation): test_west_sdk_install_is_wrapped_in_a_ retry_loop and test_a_third_failure_still_reds_the_job both fail with the expected message; restoring the fixed step passes all four again. python3.12 -m pytest python/tests/gates -q: 434 passed, 9 skipped, 2 failed (test_tan_under_test_guard.py's two subprocess-guard tests; reproduced identically checking out e01d8be, this branch's parent commit, so they predate this change and are not a regression from it). * test(ci): bind the third-attempt exit assertion to the -eq 3 branch itself test_a_third_failure_still_reds_the_job scanned the WHOLE step body for `"${attempt}" -eq 3` and, separately, for `exit\s+[1-9]\d*` -- nothing bound the second match to the first, so a hard exit anywhere else in the step satisfied it just as well as the real one inside the exhaustion branch. Proved by injection (throwaway edit to getting-started.yml, reverted after): replacing the branch's `exit 1` with `break` and appending `if false; then exit 1; fi` right after the branch's `fi` still passed all 4 tests before this change -- the exhausted retry would have ended the step green (`break` falls out of the loop with no output between it and the step's end) while the gate kept reporting the loop unbreakable. Fix: `_EXHAUSTION_BRANCH` captures the `-eq 3 ]; then ... fi` body itself (non-greedy, stopping at the branch's own `fi` -- there is no nested `if` inside it) and both the `|| true` and `exit` assertions now run against that captured body, not the full step. Re-ran the same injection after the fix: `python -m pytest tests/gates/test_getting_started_west_sdk_install_retries.py -q` now reds on `test_a_third_failure_still_reds_the_job` with `assert re.search(...) ... where None = ...`, confirming the branch body is `echo ...\n break` with no `exit`. Restored the workflow file (`git checkout -- .github/workflows/getting-started.yml`); tree clean. python -m pytest tests/gates -q (venv with tan+typer installed against this worktree): 436 passed, 9 skipped, 0 failed. --------- Co-authored-by: Caner Alp <contact@alplab.ai>
… west crash (#697) (#716) * fix(build): refuse a cross-drive project/workspace instead of letting west crash (#697) tan-cli#307's `_pin_west_workspace` redirects `west build`'s spawned cwd to the resolved Zephyr workspace so west's ancestor-`.west` walk lands on the right one -- but west's own `_sanity_check_source_dir` (`scripts/west_commands/build.py:534`, upstream, frozen) then calls `os.path.relpath(self.source_dir)` with no explicit `start`, which defaults to that same pinned workspace cwd. On Windows, `os.path.relpath` raises rather than returning a path when its two arguments live on different drive letters (`ValueError: path is on mount 'E:', start on mount 'C:'`, exactly as reported), and a raw traceback reached the user with only the generic `build.slice-failed` in the envelope. There is no fix reachable from tan's own side of the seam -- `west` is an upstream dependency, not `crates/`/`tan/planner` -- so `execute.py`'s new `_cross_drive_source_refusal` refuses the slice, naming both mounts, before `west` is ever spawned; `build_cmd.py`'s new `_cross_drive_issues` promotes that refusal into a coded top-level `issues[]` entry, `build.cross-drive-workspace`, registered in `contract/issue-codes.json`. Verified by reading, not by a live Windows repro: this box has no second drive to reproduce the ValueError against, so the fix and its tests are built against the mechanism (west's own relpath-with-no-start-arg call, and the tan-cli#307 pin that feeds it a different-drive cwd) established by reading `scripts/west_commands/build.py` and `_pin_west_workspace`, with `ntpath.splitdrive` making the drive-letter comparison itself exercisable and unit-tested on this POSIX host. Module-size budget regenerated (`--reason`) for the two modules this pushed over their recorded ceiling. * fix(build): locate the real source dir for the tan-cli#697 cross-drive refusal The tan-cli#697 refusal never fired on a real plan. `orchestrator.py`'s zephyr command builder always appends `["--", *defines]` after the source dir -- `defines` starts with `-DPython3_EXECUTABLE=...` and is never empty -- so `args[-1]` landed on that CMake define, not the source dir, on every real plan (verified against `tests/parity/oracle/multicore_rpmsg-aen. build-plan.json`'s own emitted argv). A sysbuild slice additionally inserts `--sysbuild` between the source dir and that `--` separator, so "last token before `--`" is not a safe substitute either. Replaced with `west_build_source_dir`, which locates the source dir by its actual position in the argv `orchestrator.py` emits (the token immediately after `-b`'s own value) -- correct for both the plain and sysbuild shapes. Moved alongside `cross_drive_source_refusal` into `tan.core.plan_exec` (pure argv/path logic, no IO), out of `execute.py`, which also relieves the module-size budget it had pushed over. The refusal now also sets a short `manifest_message` (naming both mounts, without the absolute paths) so `system-manifest.yaml`'s persisted reason matches the peer missing-tool refusal's own split. Tests: the nine tan-cli#697 tests were re-parametrised over a real oracle argv (`--`/defines tail included, plus a dedicated sysbuild-shape case) so they fail against the old `args[-1]` guess -- verified by reintroducing it and re-running. `test_cross_drive_refusal_names_both_mounts` no longer asserts a literal `"C:/ws"` substring, which stringifies with backslashes on a real Windows host's `Path`. The pre-fix reversion test's spawnable `west` stand-in now reuses the `_plant_spawnable_west` recipe (branches on `os.name`) instead of a POSIX-only `#!` shebang script. Added a `_build`- level test proving `_cross_drive_issues`'s promotion into `issues[]` is actually wired, not just correct in isolation -- deleting that call site left the rest of the suite green. `contract/issue-codes.json`'s note and a stale test comment updated for the function's new home. Module-size budget regenerated (shrunk, no `--reason` needed): execute.py 1687 -> 1643, build_cmd.py 2107 -> 2106. --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…717) (#718) * fix(e2e): refuse a freeze that cannot run instead of reporting it OK (#717) The gate was `[ -x dist/tan/tan ]` plus a version read inside $(...): -x passes on the PyInstaller bootloader whatever state the app inside it is in, and a command substitution discards the exit status. ModuleNotFoundError: No module named 'typer' freeze OK: FREEZE-RC=0 Now reads --version's status and output, aborts 2 on either a non-zero status or an empty version, and prints the captured stderr. * test: do not run the linux freeze-harness cases on Windows (#717) Git Bash puts a bash on PATH on windows-latest, so the presence check alone let these cases run a script whose own header gives its invocation as `MSYS_NO_PATHCONV=1 wsl -d Ubuntu-24.04 -- bash <this file>`. The windows shards went red on PR #718; ubuntu and macos still run every case. Non-vacuity re-checked on linux after the change, with scripts/e2e-linux-freeze.sh restored to origin/dev's version: FAILED tests/test_e2e_linux_freeze_script.py::test_a_freeze_that_cannot_import_its_entry_point_is_refused FAILED tests/test_e2e_linux_freeze_script.py::test_a_freeze_that_prints_no_version_is_refused 2 failed, 1 passed in 0.60s --------- Co-authored-by: Caner Alp <contact@alplab.ai>
… claim (#698) (#707) * docs(readme): give Windows its own row in 'What a build needs' #687 added the section but only ever enumerated Linux and macOS prerequisites, so a Windows reader -- a supported host with its own installer and CI leg -- found nothing there for them. Restructure the section into a per-OS list (Linux / macOS / Windows as siblings, not a Linux list with a macOS parenthetical) and leave the Windows entry as an explicit TODO(#698) placeholder rather than a guessed list; a separate session is deriving the real Windows list from an actual build. Point at `tan doctor` as the live authority for Windows the same way the section already does for the other two hosts. Verified: `python -m pytest python/tests -q` from a clean checkout of this branch -- 4107 passed, 270 skipped, 1 xfailed, 0 failed, including the four README/install gates in tests/gates/test_readme_install_prerequisites.py and the changelog.d assembler tests. * docs(readme): fill in the Windows build-prereqs row and close the ungated gap (#698) The prior commit left `- **Windows:** TODO(#698)` because the orchestrator said to wait for a Windows host to measure the list. That was unnecessary: three in-repo sources already agree, verified at HEAD before writing this -- alp-sdk `metadata/bootstrap.json` prerequisites.windows, tan.core.bootstrap's fallback_facts().prerequisites_windows, and contract/fixtures/bootstrap/ manifest.json all give ["git", "cmake", "python", "ninja"]. Also fixes a scope regression: the `west sdk install` needs `file` sentence used to sit in a POSIX-only paragraph and read as global once the section gained a Windows sibling. `file` is scoped to Linux/macOS now, and the Windows counterpart (7-Zip, from prerequisites.install.windows -- present in the SDK's install-command map but NOT in its required Windows tool list, a real gap the note now describes rather than invents past) is documented in its place. Closes the ungated defect class: `test_readme_install_prerequisites.py`'s "names every tool a build needs somewhere" check only ever read `prerequisites_posix`, so the Windows TODO placeholder passed it outright -- measured 4 passed against the pre-fix README. Checking "named anywhere in the README" for macOS/Windows would have still passed, since `git`/`cmake`/ `ninja`/`python` all show up elsewhere in the doc for unrelated reasons, so the new checks are scoped to each OS's own bullet under "What a build needs". Proven both ways: reverting only README.md back to the TODO version reddens the gate (1 failed, 3 passed, naming the missing Windows tools) with the new test code in place; restoring the fix greens it again (4 passed). Verified locally (python/, lane venv, ALP_SDK_ROOT/ALP_SDK_HAND_PORT_ROOT bound to alp-sdk pin a3173305, ALP_SDK_STRICT_LOADERS_ROOT bound to pin 26b0040e): pytest tests/gates/test_readme_install_prerequisites.py -q -> 4 passed pytest tests/gates tests/commands/test_doctor_command.py \ tests/commands/test_bootstrap_command.py -q -> 792 passed, 3 skipped pytest tests/scripts/test_assemble_changelog.py \ tests/gates/test_version_check_refuses_an_empty_changelog_section.py -q \ -> 17 passed Removes the repo's only tracked-markdown TODO (README.md and changelog.d/698.fixed.md both carried TODO(#698)). * docs(readme): drop the `file` prerequisite claim, scope the POSIX gate arm README.md - removed "on Linux and macOS, `west sdk install` also needs `file`". alp-sdk metadata/bootstrap.json manualInstallHints.posix.note[2] says the opposite: "patool's extension-based fallback works fine without it; this is WARN-only, not a bootstrap.sh prerequisite". Mirrored in this repo at python/tan/core/bootstrap.py manual_install_posix, and patoolib/mime.py:186 logs that string and falls through to extension-based guessing. - describes `tan doctor`'s real `sevenZip` check (doctor_cmd.py:1172-1206, wired at :3520-3521) instead of stating doctor would not flag 7-Zip. changelog.d/698.fixed.md - dropped the `file` sentence and the "not just macOS/Windows" claim. python/tests/gates/test_readme_install_prerequisites.py - POSIX arm scoped to the Linux bullet, as macOS and Windows already were. Measured: pytest tests/gates/test_readme_install_prerequisites.py 4 passed Linux bullet replaced with "- **Linux:** `xz`." 1 failed, 3 passed restored 4 passed + tests/commands/test_doctor_command.py 204 passed, 1 skipped Not addressed: README.md:168 claims a missing `file` makes the SDK host-tools step fail with "Host tools installation failed". That contradicts the bootstrap.json note above, but it names a different step and this branch does not touch it. Filed separately. Refs #698. --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…rites (#560) (#714) * fix(planner): give a zephyr slice's artifacts the build/ level west writes (#560) alp-sdk#1360/#1401 (merged d00dbdc1): _slice_artifacts reported <buildDir>/zephyr/zephyr.elf, a path west never creates -- the slice's command runs `west build` with cwd=<buildDir> and no `-d`, so west appends its own default `build` level and the tree lands at <buildDir>/build/. Port the fix into tan/planner/buildplan.py: all six zephyr artifact paths (elf/map/bin/sizeReport/symbols/compileCommands) now carry that level; outputDir stays null. orchestrator.py's matching change in the same alp-sdk commit is comment-only. ALL FOUR SDK PINS move together, per this repo's own lockstep rule: ci.yml's sdk_parity ref:, parity.yml's PINNED_SDK_TAG, and both PINNED_SDK_COMMIT / HAND_PORT_PINNED_SDK_COMMIT in test_planner_relocation_freshness.py, a317330595f7 -> d00dbdc12449. Two HAND_PORT deltas in the same alp-sdk range are folded into this re-sync rather than left as separate drift: - scripts/gen_zephyr_board.py (alp-sdk#1373/#1407): every AEN board's generated Kconfig.defconfig gains a `choice LOG_MODE / default LOG_MODE_MINIMAL` block (Zephyr's inherited LOG_MODE_DEFERRED starves the log thread under the non-yielding busy-loop main() the AEN bench procedure requires). Cherry-picked from the existing fix/690-hand-port-gen-zephyr-board branch (787aa2d), which had this ported but deliberately left the pin unmoved pending the alp_template.py audit below. - scripts/alp_template.py (alp-sdk#1394/#1399 + #1400): a collision guard on _derive_pin_doc_renames (two pins: entries re-deriving a SHARED doc: string to two different targets now raises instead of silently keeping whichever ran last), and _rewrite_stale_sdk_root_comment -- _scaffold_cmakelists now loops instead of subn so each guess block's own preceding comment paragraph is rewritten with it, instead of teaching a ../../.. fallback the hardened block no longer has. tan's two deliberate divergences from the oracle (the idempotence guard, and the TemplateError raise where upstream returns best-effort unchanged) are preserved. #1400 re-vendors four CMakeLists.txt files under python/tan/templates/vendored/ (edge-ai/minimal, both SKUs) -- captured bytes reused from the existing fix/1400-revendor-scaffold-templates branch (ffc814e) and independently re-verified 9/9 PASS against the real d00dbdc1 checkout. BLOCKER 1 (found in review): the pin move above took the whole suite red. alp-sdk dad5b35a ("fix(faultdecode): lead with the escalated fault, not the escalation", #1389) is also inside a3173305..d00dbdc1 and ADOPTED BOTH of tan-cli#616's declared divergences from scripts/alp_cli/faultdecode.py verbatim -- the LSPERR/MLSPERR root-cause branches and the negative-CFSR refusal. The two tests that pinned those divergences against a live oracle went red, each on its own documented "the day upstream adopts this, delete/rewrite it" instruction: - tests/core/test_faultdecode.py:: test_decode_diverges_from_the_sdk_original_only_where_tan_cli_616_declares rewritten as a plain byte-equality sweep, test_decode_matches_the_sdk_original_byte_for_byte (this is what the test asserted before #616 -- there is no longer a live SDK build to diverge from). - tests/commands/test_faultdecode_command.py:: test_the_sdk_original_decodes_a_negative_cfsr_and_tan_deliberately_does_not deleted, per its own docstring's instruction. tests/fixtures/faultdecode_golden.PROVENANCE.txt's divergence prose now records the divergence as CLOSED, citing dad5b35a/#1389 by name. Reverting these two test files and re-running against ALP_SDK_ROOT bound to d00dbdc1 reproduces the pre-fix failure exactly (2 failed, 76 passed); restored and reconfirmed green (77 passed -- one test fewer, the deleted one). BLOCKER 2 (found in review): the freshness gate's audit surface could not have caught dad5b35a landing with a real behavioural delta. scripts/alp_cli/faultdecode.py -- what tan/core/faultdecode.py is hand-ported from -- was never a key in PINNED_HASHES (scoped to scripts/alp_orchestrate/) or HAND_PORT_HASHES (scoped to tan/planner/**), so `git log <pinned>..<new> -- scripts/alp_orchestrate` came back empty and reassuring while this file drifted, unaudited, the whole time. Added to HAND_PORT_HASHES (its sha256 taken directly from the d00dbdc1 checkout HAND_PORT_PINNED_SDK_COMMIT already names, so this re-freezes nothing unaudited) with a comment explaining why it is deliberately NOT also in HAND_PORT_SOURCES (its port lives under tan/core/, not tan/planner/, so it has no tan/planner/-relative path that table's coverage half could name -- same treatment as scripts/sentinels.py already gets there). Proved the gate now catches this class: corrupted the new hash, confirmed test_hand_ported_planner_modules_match_their_pinned_sdk_source fails and names the file, restored, reconfirmed all 5 test_planner_relocation_freshness.py tests pass. MAJOR 3 (found in review): changelog.d/560.fixed.md and this message used to claim "nothing in tan reads slice.artifacts after core/build_plan.py parses it" -- false. python/tan/commands/build/execute.py:995 reads sl.artifacts.get("outputDir") (the os: baremetal staleness-disclosure check). Harmless to this fix -- outputDir is untouched and that reader never runs for a Zephyr slice -- but the changelog fragment now names the one reader instead of denying one exists. MAJOR 4 (found in review): changelog.d/560.fixed.md claimed the renode zephyr_elf_from_manifest gap was "filed as a follow-up". No such issue exists; the sentence is dropped. Confirmed but out of scope, same as before -- the reviewer will file it. MAJOR 5 (found in review): python/tan/core/system_manifest.py:756 and python/tan/core/flash_plan.py:656 both asserted, in prose, that the plan's `artifacts` block still names the un-nested <slice-cwd>/zephyr/zephyr.elf for every Zephyr slice -- true before this fix, false at d00dbdc1 for a plan tan's own in-process planner produces. Both docstrings corrected: the nested/un-nested probe stays (an older cached plan, the alp-sdk subprocess fallback pinned to a stale SDK, or a hand-authored manifest can still carry the un-nested spelling), but the reason it is still needed is now stated accurately. flash_plan.py grew 4 lines past its recorded module-size budget from this correction; re-pinned via `scripts/regen_module_size_budget.py --reason` (3079 -> 3083), system_manifest.py's growth stayed under its 800-line default cap. MINOR (found in review): test_planner_relocation_freshness.py's STRICT_LOADERS_* rationale said scripts/strict_loaders.py "does not exist at all at HAND_PORT_PINNED_SDK_COMMIT" -- true when that split was written (then 996937ac), false now that HAND_PORT_PINNED_SDK_COMMIT is d00dbdc1, where the file exists (blob d4b6ce64850acb7893ecb894a96988636cc32324, confirmed). Comment corrected to say so and name the real reason strict_loaders.py stays split (the unaudited fs_confine read-escape gap, unchanged). MINOR (found in review): the freshness gate is SDK-side-only and does not prove tan's own port applied the delta -- reverting all four ported hunks (buildplan.py, orchestrator.py, template.py, zephyr_board.py) back to their a3173305 shape while leaving the pins and hash tables at d00dbdc1 still yields 5 passed on test_planner_relocation_freshness.py (measured). The real port-side proof is tests/parity/test_planner_emit_parity.py:: test_the_subprocess_entry_points_agree_too, which DOES catch that same revert (measured: FAILS, naming the exact un-nested-vs-nested elf/map diff) and passes again with the port restored. Neither this message nor the changelog fragment cites the freshness gate as port-coverage proof any more. tests/parity/seam1_field_diff.py's vendored comparator gains a third hand-reviewed allowance, _NESTED_ARTIFACT_TAILS, mirroring alp-sdk's own: keyed on the six named artifact fields and the exact one-segment build/ insertion before each field's fixed Zephyr tail. Without it every zephyr-slice board in the frozen 97ad481b oracle fails seam-1 against a live emit at the new pin. module_size_budget.generated.json regenerated (not worked around): template.py and zephyr_board.py both grew past their recorded budgets from real, reasoned line growth in the artifacts port; flash_plan.py's budget moved separately for the Major 5 docstring fix above -- extraction would fight the whole point of a hand-port module (mirroring alp-sdk's source structure) or a load-bearing docstring. Separate, unfixed defect, confirmed but out of scope here: `tan renode`'s core/renode_plan.py::zephyr_elf_from_manifest has its own <build_dir>/zephyr/zephyr.elf fallback for a slice with no output_artefact, independent of the plan's artifacts block -- see MAJOR 3 above for the one place that block IS still read. Called directly with build_dir "m55_he-zephyr" it returns build/m55_he-zephyr/zephyr/zephyr.elf, the same one-level-short path this fix removes from the SDK-side contract. Verified this session: - tests/core/test_faultdecode.py + tests/commands/test_faultdecode_command.py, ALP_SDK_ROOT=<d00dbdc1>: 77 passed. Reverted just these two files back to their pre-fix content and re-ran: 2 failed, 76 passed, reproducing the exact review-reported failure; restored, reconfirmed 77 passed. - test_planner_relocation_freshness.py, ALP_SDK_HAND_PORT_ROOT= ALP_SDK_ROOT=ALP_SDK_STRICT_LOADERS_ROOT=<d00dbdc1>: 5 passed. Corrupted the new faultdecode.py hash and re-ran: 1 failed, naming the file and the exact sha256 mismatch; restored, reconfirmed 5 passed. - tests/parity/test_planner_emit_parity.py:: test_the_subprocess_entry_points_agree_too, ALP_SDK_ROOT=<d00dbdc1>: 1 passed. Reverted the four ported planner files (buildplan.py, orchestrator.py, template.py, zephyr_board.py) to their pre-fix content while leaving the pins/hashes at d00dbdc1, and re-ran BOTH this test and test_planner_relocation_freshness.py: this test FAILED (naming the exact un-nested-vs-nested elf/map diff), the freshness gate still showed 5 passed -- confirming the minor finding that the freshness gate alone does not prove the port landed. Restored, reconfirmed both green. - tests/core/test_flash_plan.py + tests/core/test_system_manifest.py: 81 passed (docstring-only edits, no behavioural change expected or seen). - test_module_size_budget.py: 6 passed. - python -m pytest tests -q, ALP_SDK_ROOT=<d00dbdc1> (the full suite): 5012 passed, 59 skipped, 1 xfailed, 0 failed, in 938s. Pre-fix baseline measured the same way this session, same pin, same command: 2 failed (the two named in Blocker 1), 5011 passed, 59 skipped, 1 xfailed -- the ONLY two failures anywhere in the suite at this pin, both closed. * fix(tests): gate the faultdecode oracle sweep on its pinned SDK vintage test_decode_matches_the_sdk_original_byte_for_byte (python/tests/core/ test_faultdecode.py) byte-diffed against whatever alp-sdk sibling checkout _resolve_oracle_path found, with no vintage check: a checkout older than alp-sdk dad5b35a (#1389) still carries the pre-fix _root_cause ladder with no LSPERR/MLSPERR branch, so the sweep reported 18 false mismatches with no indication the SDK checkout, not the port, was stale. The test now calls _require_pinned_oracle_vintage, which sha256-checks the resolved oracle against HAND_PORT_HASHES["scripts/alp_cli/faultdecode.py"] (the same pin test_planner_relocation_freshness.py's own hand-port gate tracks) and skips, naming the required commit and both hashes, on a mismatch. test_planner_relocation_freshness.py's HAND_PORT_HASHES gains eight more scripts/alp_cli/*.py entries (diagnostic_format, validate, new_som, doctor, explain, monitor, model, validator) at HAND_PORT_PINNED_SDK_COMMIT (d00dbdc1) -- each is named as a hand-port source in a comment under python/tan/ and had no freshness-gate coverage at all. test_zephyr_board_aen_log_mode_default.py's slot0_base fixture value moves from the fabricated 0x80080000 to 0x80010000, the real E1M-AEN801 he_slot0 base (metadata/e1m_modules/E1M-AEN801.yaml). --------- Co-authored-by: Caner Alp <contact@alplab.ai>
… (#710) * fix(flash): correct select_flash_method's stale Flow D docstring (#700) The "Consequence, stated plainly" paragraph claimed _slice_flash_recipe (tan/planner/orchestrator.py) returns ("zephyr_west_flash", {}) for every Zephyr slice, so no entry ever carries FLOW_D_KEYS and every AEN slice still takes Flow A. That is false against the code it cites: _slice_flash_recipe already populates jlink_flash_device (and, when the SoC variant publishes them, the expect_dpidr/jlink_device preflight pair and slot0_load_address) from SoM-preset metadata resolved via tan/planner/loader.py. A real E1M-AEN801 project's Zephyr slice already carries FLOW_D_KEYS on emit and dispatches to Flow D today. Rewrite the paragraph to describe the current emit; the ADR-0017/I-26 rationale above it is unchanged and still correct. * fix(flash): correct 8a988c3's own inverted Flow D provenance claim 8a988c3 ("correct select_flash_method's stale Flow D docstring", #700) was itself wrong, in both the code and its own commit message. Its "Consequence, stated plainly" paragraph, and the message that introduced it, claimed slot0_load_address comes from the SoC variant's `debug:` block alongside jlink_flash_device/expect_dpidr/jlink_device. That inverts tan/planner/loader.py::_resolve_slot0_load_address's own docstring (loader.py:299-301): slot0_load_address is deliberately sourced from the SoM preset's `memory_map:`, NOT from the debug: block jlink_flash_device lives in -- it is SDK/module build POLICY, not a silicon fact (alp-sdk#1069), because two SoMs on the same silicon part can pick different slot0 windows. loader.py's own call site (_resolve_slot0_load_address(som_preset, core_id)) never touches variant_debug. This commit rewrites the paragraph to attribute each field to its real source. Also fixes a second, older false claim the first commit left standing sixty lines above its own fix: the FLOW_D_KEYS comment still said slot0_load_address "does not exist in any alp-sdk branch today", which is false against the same emitter (tan/planner/orchestrator.py) since alp-sdk#1374/tan-cli#353, and was now self-contradicted by the paragraph 8a988c3 itself added. Net +14 lines pushed tan/core/flash_plan.py from 3079 to 3093 against its recorded budget of 3079 (dev's own gate is unaffected -- this module was already sitting exactly on its ceiling before 8a988c3's own +4 lines pushed it over); regenerated module_size_budget.generated.json and logged the reason in MODULE_SIZE_BUDGET_LOG.md. Adds changelog.d/700.fixed.md, missing from 8a988c3. * fix(flash): stop attributing jlink_flash_device to "SoM-preset metadata" select_flash_method's docstring said jlink_flash_device/expect_dpidr/ jlink_device come "from SoM-preset metadata resolved via tan/planner/loader.py". They come from the SoC variant's debug: block (soc_spec, e.g. metadata/socs/alif/ensemble/e8.json), selected via but not carried by the SoM preset's silicon_variant (loader.py::_resolve_variant_debug). Reworded that sentence and a nearby one with the same framing ("did the SoM preset hand me a part-number J-Link profile"). Also dropped two literal Alif part numbers (AE822FA0E5597BS0/ AE822FA0E5597LS0) and an E1M-AEN801 SKU name the docstring had picked up, which failed test_flash_command.py::test_flow_d_holds_no_part_number_of_its_own. Net -4 lines on tan/core/flash_plan.py (3093 -> 3089); no budget regen needed. python -m pytest tests/core/test_flash_plan.py tests/gates/test_module_size_budget.py tests/commands/test_flash_command.py::test_flow_d_holds_no_part_number_of_its_own tests/scripts/test_assemble_changelog.py -q -> 25 passed in 3.70s python -m pytest tests/commands/test_flash_command.py tests/commands/test_flash_pipeline.py tests/core/test_setools.py tests/core/test_helper_flash_policy.py tests/core/test_swd_probe_shipped_preset_shape.py tests/core/test_sdk_revision_gate.py -q -> 461 passed, 8 skipped in 108.30s --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…#720) (#723) `tan init` spelled the SoM SKU `--som`; `tan pinmux` and `tan new-som` spelled the identical value `--sku`. A customer who scaffolds with tan init --som E1M-AEN801 ... and then runs `tan pinmux --som E1M-AEN801` is rejected for the flag `init` just taught them, and vice versa. The value is the same string in all three places: `tan presets` prints it under `skus=`, and `board.yaml` nests it as `som.sku`. Each command keeps its existing flag FIRST and gains the other as an alias, so help text, error messages and every existing script are unchanged -- this only widens what is accepted: tan init --som (canonical) + --sku (alias) tan pinmux --sku (canonical) + --som (alias) tan new-som --sku (canonical) + --som (alias) Verified on Windows against the full suite: 37 failed / 4082 passed / 307 skipped, and every one of those 37 reproduces on clean origin/dev at the same path -- this change adds no regressions. The 37 are host-environment failures (15 shell-completion, 10 install.ps1 layout, 8 process-spawn, 2 faultdecode SDK-parity resolving against a sibling alp-sdk checkout that is not at the pinned commit, plus 2 more present in the dev baseline). Module size budget regenerated: tan/commands/init_cmd.py 1247 -> 1258, tan/commands/new_som_cmd.py 1353 -> 1361. Co-authored-by: Caner Alp <contact@alplab.ai>
…724) comports() reports the bare COM<n> from the registry PortName, so a membership test against it refused a port pyserial would have opened -- while listing that same port in the not-found message. Measured on a real Windows host: serial.Serial(r"\\.\\COM38") -> opens, is_open = True comports() device string -> 'COM38' tan monitor --port "\\.\\COM38" -> monitor.no-port : not found _port_aliases() resolves the device-namespace prefix back to the bare name before the membership test. Normalised, not widened: a UNC port whose bare form is absent is still refused. $ pytest tests/commands/test_monitor_command.py -q 30 passed in 0.46s $ pytest tests/gates -q 465 passed in 14.19s With the alias resolution removed, tests unchanged: FAILED ...::test_the_unc_spelling_of_a_present_port_is_accepted FAILED ...::test_a_posix_device_path_is_not_rewritten 2 failed, 28 passed in 0.36s the still-refused case stays green under that neuter, so the fix is specific. Co-authored-by: Caner Alp <contact@alplab.ai>
…ro failures (#725) (#726) * fix(tests): absorb a probe timeout instead of aborting collection (#725) `_bash_available()` bounded its probe with `timeout=10` and caught only `OSError`. `subprocess.TimeoutExpired` derives from `SubprocessError`, NOT from `OSError`, so the one failure mode the budget existed to bound was the one the handler did not absorb. The probe runs at MODULE scope -- the `@pytest.mark.skipif(...)` decorators call it at import time -- so the escaping exception did not fail a test, it aborted COLLECTION of the whole file: tests\commands\test_completion_command.py:479: in <module> not _bash_available(), reason="no real bash on this host ..." E subprocess.TimeoutExpired: Command '['bash', '-c', 'echo tan-bash-ok']' timed out after 10 seconds !!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!! pytest then exits 2 having run nothing, printing zero FAILED lines -- so a branch-vs-baseline failure diff reads every known failure as newly PASSING. Measured while doing exactly that: a comparison that should have read "37 failures, 0 regressions" instead read "0 failures, 37 newly passing", from a run that executed no tests. Nothing is wrong with bash -- it resolves to Git Bash and works. It is cold process start-up under load: 10.6s cold against the 10s budget, 0.10s warm. Any budget can be exceeded, so the handler is the fix, not the number. `_noexec_probe()` in tests/installers/test_installer_release_layout.py had the identical shape (timeout=15, `except OSError`, called at module scope by `noexec_capable`) and is fixed with it. Two neighbours were checked and are already correct: `_bash_setlocale_warning_probe` catches `subprocess.SubprocessError`, and conftest.py's `_git()` catches `(OSError, subprocess.SubprocessError, UnicodeDecodeError)`. Both are narrowed to `TimeoutExpired` rather than `SubprocessError` deliberately. For a host-capability probe a timeout IS an answer -- it means the same as "no bash on PATH": this host cannot usefully run these tests, so skip. That is the opposite of the rule for production code, where test_diff_command.py's `test_sdk_validator_timeout_refuses_instead_of_reporting_clean` records a blanket `except ... SubprocessError` swallowing a timeout as a MAJOR defect. The narrower catch keeps that distinction visible at the seam. tests/gates/test_capability_probes_absorb_timeouts.py covers both probes three ways: a timed-out probe returns False instead of raising, a missing tool still returns False, and a working host still returns True (a fix that made every host look incapable would skip these suites everywhere). It patches `subprocess.run` rather than running anything slow, so it is hermetic. Verified it can fail: against the pre-fix probes the new file cannot even be COLLECTED -- importing test_completion_command runs the module-scope probe, which raises TimeoutExpired -- which is the defect reproducing itself. With the fix, 6 passed. `_bash_available` is `@lru_cache(maxsize=1)` and already called at import time, so the first cut of these tests read that one cached verdict and exercised none of the code they named. They now clear the cache before each call, and an autouse fixture clears it afterwards so a verdict computed against a faked `subprocess` cannot leak into the rest of the session. * fix(tests): retry the bash probe once so a cold spawn does not skip 15 tests (#725) Absorbing the timeout stopped the collection abort but was not enough on its own, and the measurement says so: with only the handler in place the full suite went from 37 failed, 4082 passed, 307 skipped to 20 failed, 4088 passed, 325 skipped Zero regressions -- but the 17 "fixed" failures were not fixed. Skips rose by 18. The 15 bash-completion tests moved from FAILED to SKIPPED because the probe timed out and the new handler answered "this host cannot", so a loud collection abort had simply been traded for quietly untested code -- on exactly the loaded hosts that provoke the bug in the first place. The only observed cause is cold process start-up (10.6s cold, 0.10s warm), so `_bash_available` now makes a second attempt: the first spawn pays the cold cost, the second is warm and answers. Measured after this change, the 15 tests run again (15 failed, 32 passed, 3 skipped -- the known Windows-environment failures) instead of skipping. A healthy host never reaches the second attempt, so this costs nothing where nothing is wrong. `_noexec_probe` deliberately does NOT retry: an `unshare` namespace probe that times out is reporting a genuinely restricted host, not a warm-up cost, and retrying it would just double the wait before the same answer. `test_bash_probe_retries_once_so_a_cold_spawn_does_not_silently_skip` pins it by counting spawns: a first-call timeout followed by a success must return True after exactly 2 calls, so a future simplification back to a single attempt fails the test rather than silently reintroducing the skip.
…709) * fix(flash): stop hard-failing tan flash on an os: "off" core (#699) board.yaml cores declared os: "off" are correctly excluded from tan build's buildable-slice set (there is no app, no board target), but their build/system-manifest.yaml entry never advances off the plan-time status: pending default, since nothing ever overlays a real outcome onto it. plan_flash_targets read that as an incomplete/failed build and hard-refused the whole run with flash.slice-not-built ("... Rebuild it first"), advice the core can never satisfy. tan image, reading the same manifest entry, already degraded gracefully (image.slice-skipped, ok: true) -- the two commands disagreed about one piece of state. Fixed in flash's reader, not the manifest emitter: plan_flash_targets now checks the slice's os field (already present on the parsed manifest, independent of whatever never-advanced status the emitter left behind) and routes an os: "off" slice into the same non-fatal refused_skipped/flash.slice-skipped bucket status: "skipped" already uses, with its own accurate message. The emitter (planner/models.py, Slice.to_manifest_entry) was deliberately left alone: it is a byte-identical relocation of alp-sdk's own scripts/alp_orchestrate, governed by tests/parity/test_planner_emit_parity.py, and alp-sdk's upstream source carries the identical status = "pending" default for os: "off" cores -- diverging tan's copy would silently break that relocation-parity contract the moment any board.yaml declares an off core, without fixing anything upstream. Reproduced against a real tan init --cores m55_hp:zephyr,m55_he:off scaffold (the exact shape tan init --help documents) plus a simulated post-build manifest, both via the pure planner/flash_plan functions and the real tan flash --dry-run CLI; confirmed the fix by reverting flash_plan.py alone and watching flash.slice-not-built reappear on m55_he, then restoring it. Regenerated the module-size budget ratchet for the two files this grew (flash_plan.py, flash_cmd.py's matching divergence-note docstring). * fix(flash): correct false operator-facing off-core claims (#699 follow-up) The behavioural fix in the previous commit is right, but three sentences it left behind are contradicted by the manifest tan itself emits: - flash_plan.py's refused_skipped warning for an os:"off" core, and the matching comment, both asserted "there is no app and nothing was ever built for it, by design" / "no app, no board target". False: board.yaml may declare app:/board:/toolchain: for an off core independent of os:, and the manifest carries them. Reworded to say only what's true: tan build never builds a core declared off, by design (tan/planner/ orchestrator.py:36). Same correction applied to changelog.d/699.fixed.md. - flash_plan.py:322-339 and flash_cmd.py's matching docstring referred, in the present tense, to "the shipped Rust plan_flash_targets" and to tests/parity/test_flash_oracle_parity.py's deliberate lack of a status:skipped case. Both crates/ and that test file were deleted in 2883cdf, before this branch's base commit. Moved to past tense and named the retiring commit. - flash_cmd.py's refused_skipped Issue-append comment explained the bucket only via executionPolicy, which no longer covers the os:"off" shape this branch added. Extended it to name both. - Documented, rather than silently relying on, why checking found.os before found.status is safe today (an off core never receives a SliceRunResult, so its status can only ever be the pending default) and what would need to change if that ever stopped being true. Regenerated the module-size budget ratchet for the two files this grew. Verified: tests/commands/test_flash_command.py, tests/core/test_flash_plan.py and tests/gates all green (812 passed, 9 skipped); tests/gates/ test_planner_relocation_freshness.py green with ALP_SDK_ROOT bound at the pinned commit. * docs(planner): correct the "byte-identical relocation" claim about models.py 2a4093b's commit message called tan/planner/models.py "a byte-identical relocation of alp-sdk's own scripts/alp_orchestrate", defending why the os:"off" flash fix left the emitter alone. Measured false at the branch's declared pin (alp-sdk a317330595f744d35f4d785869517110f3678f70): tan/planner/models.py is 460 lines against upstream's 452, diverging in the relative-vs-absolute import in Slice.to_manifest_entry and three exception docstrings (SdkRevisionUnsupported and its two siblings) reworded because the alp-sdk caller they named has no tan-cli counterpart. The decision the commit message defended is still correct: system-manifest emit IS in test_planner_emit_parity.py's mode list (:89) and is diffed byte-for-byte against alp-sdk's own front door, and test_planner_relocation_freshness.py -- which hashes only the alp-sdk side of the comparison, by its own docstring, "deliberately... not a byte-identical port comparison" -- passes clean with ALP_SDK_ROOT bound at the pin. What was wrong was the claim's wording, not the reasoning. Added a module-header note to models.py stating the actual guarantee (byte-identical OUTPUT, not byte-identical source) so a future reader doesn't inherit the same overstatement from a commit message a source-diff would have to go find. Verified: tests/gates/test_planner_relocation_freshness.py, tests/core/test_flash_plan.py and tests/commands/test_flash_command.py green with ALP_SDK_ROOT/ALP_SDK_HAND_PORT_ROOT bound at a3173305 and ALP_SDK_STRICT_LOADERS_ROOT at 26b0040e (both pins this branch declares); tests/gates/test_module_size_budget.py green (models.py stays under the 800-line default cap, no ratchet entry needed). * fix(flash): remove remaining false off-core claims 8f7490f missed - test_flash_command.py: test_an_off_core_does_not_fail_flash's docstring still said "there is no app, no board target" for an os:"off" core. False -- a real `tan build --native` manifest emit for E1M-AEN801 m55_he carries `app: alp-stock-shim` and `board: alp_e1m_aen801_m55_he/ae822fa0e5597ls0/rtss_he`. - flash_plan.py (x2), flash_cmd.py: "(the oracle predated os:\"off\" cores)" / "(and predated os:\"off\" cores entirely)". False -- the os:"off" concept already existed in crates/tan-core/src/ system_manifest.rs (commit 2a37bee, 2026-07-19 17:41) before flash/mod.rs was even split out of flash.rs (commit fad81ab, same day 21:26) and before its last edit (42525b0, 2026-07-21); flash/mod.rs just never added an os carve-out. - flash_plan.py (x2), flash_cmd.py: "already made and reported" / "tan build made and reported" describing an os:"off" refusal. tan build's own output (JSON slices list, --plan text recap) never mentions an off core at all -- confirmed via a real `tan build --native --format json` run, whose `data.slices` list contains only the non-off cores. Reworded to "already made" (board.yaml declares it), dropping the unproven "reported" half. - models.py: header comment listed exactly two divergences from upstream as if exhaustive; a real diff against the pinned alp-sdk checkout shows at least two more (an `#1069`-referencing comment block dropped entirely, an issue-number prefix added). Reworded to "including (not limited to)". Verified: tests/commands/test_flash_command.py, tests/core/test_flash_plan.py, tests/gates/test_module_size_budget.py, tests/gates/test_planner_relocation_freshness.py green (387 passed) with ALP_SDK_ROOT/ALP_SDK_HAND_PORT_ROOT bound at a317330595f744d35f4d785869517110f3678f70 and ALP_SDK_STRICT_LOADERS_ROOT at 26b0040e9a762c16aff5c7c53b2e19cc7583b2a4. --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…#729) The planner picks chip symbols from `chips/<slug>/` on disk (`_chip_has_driver`). That is the right intent -- the directory is what the declaration compiles -- but it is an INFERENCE, while Zephyr resolves the Kconfig DECLARATION. When a tan and an alp-sdk disagree about which chips have drivers, the emitted line surfaces three layers downstream as: alp.conf:28: warning: attempt to assign the value 'y' to the undefined symbol ALP_SDK_CHIP_DP83825 error: Aborting due to Kconfig warnings 0 of 3 slice(s) built taking out `tan build` for the whole SoM and blaming a generated file the customer never wrote. Measured shape of the skew: released `tan 0.5.1`, whose vendored planner predates alp-sdk#1241/#1322, against alp-sdk `dev`, whose `ethernet_phy: dp83825` made an undriven chip reachable from `on_module:` for the first time. `metadata/chips/dp83825.yaml` records `driver_status: none` and the SDK declares no `ALP_SDK_CHIP_DP83825` anywhere, so the symbol genuinely does not exist -- the old planner emitted it regardless. Each symbol is now verified against the declarations parsed from the bound SDK's `zephyr/**/*.kconfig` before any line is written. A mismatch raises `OrchestratorError` naming both the symbol and the chip, and the SDK it was read from, so the reader learns which pair disagrees instead of hunting a `CONFIG_` line they never authored. This does NOT repair tan 0.5.1 -- a released binary cannot be retro-fixed, and that half is release sequencing (ship tan and alp-sdk together, see #728). It stops the NEXT skew of this class from being discovered by Zephyr. Two deliberate non-behaviours: - Silent when it cannot verify. An empty declaration set means the kconfig tree could not be read or is structured differently, not that nothing is declared. Refusing every build on a layout assumption would be a worse failure than the one being prevented. - No change on a healthy pair. Measured on alp-sdk dev: 80 chips/<slug>/ directories, 80 declared ALP_SDK_CHIP_* symbols, zero divergence either way. A real `tan build` after this change still reports `2 of 3 slice(s) built` with DP83825 emitted 0 times. tests/planner/test_chip_symbol_declared_guard.py covers the refusal by name, the pass-through when the sets agree, the stay-silent case, and a live assertion against the bound SDK that every symbol this tan would emit is declared -- the check that would have caught #728 at plan time.
…ly note (#706) (#731) #706 read the README and alp-sdk metadata/bootstrap.json as contradicting each other on whether west sdk install needs `file`. They do not: bootstrap.json manualInstallHints.posix.note[2] is written for the --no-hosttools invocation in its own note[0], and the README's command does not pass that flag. The host-tools step is what fails without `file`. The README sentence is correct and stays -- doctor_cmd.zephyr_sdk_check records the experiment (pristine ubuntu:24.04, `file` the only variable, with it exit 0 "All done", without it exit 1 "Host tools installation failed"). The README now names the --no-hosttools difference so the two documents stop reading as opposites, and two gate cases tie the claim to the command. Neuter, deleting the sentence while keeping the command: 2 failed, 4 passed in 0.24s Neuter, adding --no-hosttools while keeping the sentence: 1 failed, 5 passed in 0.46s Restored: 465 passed, 9 skipped in 14.25s Co-authored-by: Caner Alp <contact@alplab.ai>
…e a flag (#719) (#722) * fix(flash): refuse a run that wrote nothing, and give the confirm gate a flag (#719) tan flash returned ok:true / exit 0 with every slice status:planned, so `tan flash && echo flashed` printed flashed over an untouched device. The flash.confirm-required warning was in issues[], which a caller checking $? or ok never reads. - a run with >=1 planned target and no written target appends flash.nothing-flashed and exits non-zero; --dry-run is excluded - new --confirm flag, OR-ed with ALP_FLASH_FORCE=1 and flash_args.confirm - confirm_gate_note() single-sources the remedy; two of the three sites that composed it never named ALP_FLASH_FORCE=1 $ pytest tests/commands/test_flash_command.py -q 371 passed in 29.35s With the verdict neutered (`if False and ...`), tests unchanged: 10 failed, 361 passed in 29.66s the dry-run and --confirm cases stay green under the neuter, so the refusal is specific rather than a gate that rejects every run. * test(gates): raise the ratchet for the #719 flash-command growth tan/commands/flash_cmd.py: 3922 -> 3966 tan/core/flash_plan.py: 3079 -> 3102 Full suite before the raise: 1 failed, 5062 passed, 57 skipped, 1 xfailed in 747.81s. tests/gates after: 465 passed in 14.83s. --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…733) The gate tested set membership against comports(), refusing two classes of working port: /dev/serial/by-id/... symlinks (pyserial reports raw nodes, never the by-id path) and pyserial's URL handlers (socket://, rfc2217://), which have no local device path at all. The module docstring stated the rule as refusing a port that does not exist; the code was stricter than that. Measured on this host with a real Artery AT32 adapter: serial.Serial() opens the by-id symlink, comports() omits it, and tan refused it while listing its own raw node /dev/ttyACM0 in the refusal. _port_is_usable now has three accepting arms -- enumerated (including #701's alias), a character device, or a pyserial URL scheme. Scheme set read from serial.urlhandler via pkgutil, not hardcoded; measured on pyserial 3.5 as alt/cp2110/hwgrep/loop/rfc2217/socket/spy. No pyserial yields an empty set, leaving the gate as strict as before. Neutered to return True: 3 failed, 31 passed, 1 skipped in 0.41s Restored: 497 passed, 10 skipped in 14.07s Co-authored-by: Caner Alp <contact@alplab.ai>
… (#730) doctor reported [pass] sdk for a path with no scripts/alp_project.py, while tan build refused the same path. --sdk-root is the terminal tier, so resolve_sdk_root_ladder hands it back unvalidated; build_cmd, run_cmd, validate_cmd, clean_cmd and flash_cmd each guard at their own call site with is_sdk_root, and doctor did not. Detail comes from shapes.rejected_sdk_root_message, the spelling five other commands already use, not a sixth one. sdkProvenance is skipped for the same path -- it rendered a missing directory as pass | alp-sdk at <path> (no git checkout / metadata/sdk_version.yaml). Neutering the guard to dangling_flag_root = None fails both end-to-end tests and leaves the positive control green: 2 failed, 4 passed, 200 deselected in 0.76s With the guard: 668 passed, 10 skipped in 23.84s Co-authored-by: Caner Alp <contact@alplab.ai>
…) (#735) A SoC variant declaring `"jlink_flash_device": null` -- a schema-declared "this variant has no known J-Link flash profile" -- was dropped by the emitter's truthiness test, so the key arrived at flash_plan ABSENT, flow_d_available() returned False, and tan flash silently downgraded Flow D to Flow A over the SE-UART with no diagnostic. flash_plan decides on key presence (_fa_has_key) precisely to prevent that; the emitter destroyed the distinction first. Root cause is the loader: _resolve_jlink_flash_device returns debug.get(...), which collapses declared-null and absent to the same None. Adds _jlink_flash_device_declared() and Slice.jlink_flash_device_declared to carry presence alongside the value: declared null value=None declared=True absent value=None declared=False real string value='AE722F80F55D5LS_M55_HE' declared=True Four sites move to presence: the emitter, slot0_load_address, and _enforce_flow_d_preflight_pair's scope guard -- whose docstring already said presence promotes while the code tested truthiness. Neuter back to truthiness: 2 failed, 7 passed in 0.31s Neuter to emit unconditionally: 3 failed, 6 passed in 0.29s Restored: 9 passed in 0.28s 494 passed, 16 skipped in 14.07s Co-authored-by: Caner Alp <contact@alplab.ai>
scripts/alp_orchestrate/ had moved 19 commits past the audited pin and the dispatched parity suite was red. The behavioural delta is one file -- secure.py, +82/-1 -- which is alp-sdk#1413. emit_sysbuild_conf defaulted boot.swap_algorithm unconditionally to scratch. On an AEN SKU declaring per-role <role>_slot0 windows (alp-sdk#1069) the DT has no slot1 and no scratch partition, so SB_CONFIG_MCUBOOT_MODE_SWAP_SCRATCH=y described a boot that cannot happen. Single-slot targets now default to SINGLE_APP; an explicit scratch/move/overwrite there is a loud OrchestratorError. _boot_target_is_single_slot reuses zephyr_board._aen_role_slot0_map rather than scanning memory_map region names, which disagree in general. Re-pinned all four pins together (the gate warns a split measures tan against two alp-sdks at once): PINNED_SDK_COMMIT, HAND_PORT_PINNED_SDK_COMMIT, parity.yml PINNED_SDK_TAG, ci.yml ref:. STRICT_LOADERS_PINNED_SDK_COMMIT stays -- strict_loaders.py is not in the diff. The re-pin also brings in alp-sdk#1439, which removed flash_method: swd_probe from the four gd32_bridge entries. Four assertions measuring that block now skip visibly, and ONLY when all four presets agree -- a partial removal still fails as one-PCB drift (proven: 4 failed with swd_probe back on E1M-V2N101 alone). Neuter to the unported code: 5 failed, 6 passed in 0.30s Restored: 11 passed in 0.29s 1396 passed, 8 skipped in 22.28s Co-authored-by: Caner Alp <contact@alplab.ai>
The check rode the zephyrSdk Fail (`and not zephyr_sdk_ok`), so a Windows host that already has a Zephyr SDK but no 7-Zip got no signal, and its next `west sdk install` died with `Zephyr SDK setup requires '7z'`. Measured on Windows with 7z stripped from PATH and the SDK present: overall ok: True any check mentioning 7z: [] The failed premise is in seven_zip_check's own docstring -- "a host that already has the SDK never reaches this". Adding a second architecture's toolchain is ordinary and zephyrSdk passes throughout. Docstring corrected rather than left stale beside changed code. Severity unchanged: still warn, not fail. Missing 7-Zip blocks the remedy, not the build. Only the gate was wrong. An existing test asserted the opposite on the same false premise; inverted, and it now also asserts zephyrSdk really passed so it cannot succeed for the old reason. A second case pins the warn severity. Neuter back to `and not zephyr_sdk_ok`: 2 failed, 4 passed, 201 deselected in 0.60s Restored: 6 passed, 201 deselected in 0.49s 671 passed, 10 skipped in 23.63s Co-authored-by: Caner Alp <contact@alplab.ai>
…only setools claim (#739) (#740) * fix(doctor): drop the unverified J-Link firmware floor and the Linux-only setools claim (#739) Two flash-readiness messages stated hardware requirements the AEN EVK silicon run contradicts. Both are customer-facing and both would have shipped in v0.6.0 as written. 1. `jlink_check` asserted Flow D needs "a probe on matched J-Link V13 firmware", and its docstring stated it as fact. Measured on the AEN EVK, a probe reporting `Firmware: J-Link V11 compiled Apr 1 2025 10:02:30` / `Hardware version: V11.00` connected WITH the part-number profile (AE822FA0E5597LS0_M55_HE; Flow D refuses the generic Cortex-M55, so the profile is not in question) and programmed MRAM repeatedly, including a 96 KiB loadbin + verifybin at 0x80560000 that byte-verified. The claim is DROPPED, not restated as "V11+": where the true floor sits is untested, and swapping one unmeasured minimum for another is the same defect. What remains are the two established requirements -- the part-number device profile and the V9.46 DLL floor. 2. The `setools` check's scoping sentence was right (SE-UART flashing IS Linux-only -- alif_flash.py hard-codes app-release-exec-linux) but left a Windows operator believing the `-linux` bundle is what `--setools-dir` must point into. Measured on the same host, Flow D signed with the WINDOWS SETOOLS build (app-gen-toc.exe under a Windows app-release-exec, SETOOLS_version_SE_FW_1.110.00_DEV), producing an ATOC the part booted from. The two paths are now separated explicitly. The new test asserts the ABSENCE of any firmware claim across all four jlink_check arms rather than the presence of a number: the retired claim rode in the shared `requirements` string plus two `fix` hints, so a single-arm assertion would not have caught it. The existing test that pinned `"V13" in blob` now pins the DLL floor. Both corrections come from the alp-sdk#1380 bench runs, so the doctor text and the silicon now agree. * chore(gates): re-ratchet doctor_cmd.py after the #739 evidence docstrings --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…nned one (#741) (#742) The check asserted equality between `doctor.sdk.root` (where the checkout IS) and `bootstrap --dry-run`'s `data.sdkRoot` (where it WOULD BE). Those answer different questions, and relocating the checkout into the workspace is deliberate and announced (tan-cli#185) -- so the assertion failed on every host where bootstrap can succeed, and passed only where it refused for missing prerequisites and therefore planned no move. Measured, same script and same tan build: a pristine ubuntu:24.04 container PASSed with bs2 exiting 1, while a provisioned Linux host FAILed with bs2 exiting 0 and doctor=.../proj/alp-sdk against bootstrap=.../proj/alp-workspace/alp-sdk. The container's PASS was not the two agreeing -- it was one side not running, which the harness's own #323 records in the very next block as "no 'would' verb (no relocation planned)". The assertion is now the invariant that holds on BOTH shapes: no relocation planned -> the roots must be identical; relocation planned -> the destination must sit inside the `workspaceDir` reported in the same envelope. Post-bootstrap agreement is a separate assertion already covered by "#299 doctor AFTER a successful bootstrap", which passes on both hosts. tan-cli#358 had already tightened this from non-emptiness to equality. That was right to reject non-emptiness; the pair it chose to compare is the half still wrong. Verified falsifiable by exercising every branch: 2 pass (both real host shapes), 3 fail (planned root outside the reported workspace, planned move with no workspaceDir, either side resolving nothing). No shipped tan behaviour changes -- this is the harness.
…ive Zephyr cores (#744) (#745) * fix(planner): scope the slot0 collision guard to live Zephyr cores (#744) Ports alp-sdk#1445. _enforce_slot0_disjoint_across_roles compared slot0_load_address on BOTH m55 roles regardless of os. alp-sdk#1295 populated debug.jlink_flash_device for the E3/E5/E6/E7 variants, making the guard reachable for the first time; a core parked with os: "off" produces no flashable artifact, so its resolved address is moot and a collision with it cannot physically happen. Mirrors _enforce_flow_d_preflight_pair's own os != zephyr guard. Neuter (scoping removed): 2 failed, 10 passed in 0.35s Restored: 12 passed in 0.29s * test(planner): re-pin to alp-sdk bd8be484 and move the metadata-root test to V2N (#744) alp-sdk#1447 gave all six AEN SoMs an explicit memory_map:, which returns verbatim and never consults the metadata root -- so the E3 bank rename in test_metadata_root_override.py became invisible and four of its five tests failed against the new pin. Retargeted E1M-AEN301/e3.json/SRAM6 -> E1M-V2N101/n44.json/ocram_low. V2N/V2M are the only SKUs published preliminary:false AND partial_hw_config:false, and they are now the only family taking the SoC-JSON-derived branch this test guards -- the branch whose bug blamed the customer's board.yaml while listing another tree's device names. Rename walks memory_regions[] rather than variants[].sram_banks_kb; the RZ/V2N SoC JSON declares regions at the top level and its variants carry no bank map, so the Alif-shaped walk would have renamed zero entries silently. Coverage preserved: reproducing the original bug (metadata_root ignored) still gives 3 failed, 2 passed. Restored: 5 passed. Re-pinned all four pins 56dea6b5 -> bd8be484 plus three PINNED_HASHES. STRICT_LOADERS_PINNED_SDK_COMMIT unchanged -- not in the diff. 1402 passed, 8 skipped in 22.50s * fix(planner): gate slot0_load_address on the VALUE, matching alp-sdk (#744) tan-cli#737 made slot0_load_address ride the presence promotion used for the flash_args emit. alp-sdk gates it on the value. The two agreed until a variant declared jlink_flash_device: null -- alp-sdk#1447 made e4.json do that, and tan then emitted a slot0_load_address alp-sdk does not. Caught by the byte-parity suite on examples/peripheral-io/usb-host-storage (E1M-AEN401): 18a19 > slot0_load_address: '0x802b0000' 27a29 > slot0_load_address: '0x80010000' A variant with no J-Link profile cannot run Flow D, so there is no slot0-XIP address to publish. After the revert the emit is byte-identical to alp-sdk's. * chore(gates): re-baseline the module ratchet after the slot0 revert (#744) 2169 passed, 16 skipped in 505.93s (0:08:25) with tests/parity included -- the suite that compares tan's emitters byte-for-byte against alp-sdk's, and the one whose omission let the slot0_load_address divergence reach CI. --------- Co-authored-by: Caner Alp <contact@alplab.ai>
…rched PATH twice (#747, #746) (#748) * fix(size,build): budget FLASH per slot0 window; stop printing PATH twice (#747, #746) Both found in one clean-room customer e2e on Windows -- fresh clones of both repos at dev, isolated HOME, PATH stripped of every Python3x\Scripts and Programs\tan entry, tan installed from dev source into its own venv, building a scaffolded E1M-AEN801 project. whole 5.5 MB part) while each M55 links into its own 2688 KiB slot0 (alp-sdk#1445/#1069). Against the same image the linker had just reported: 113.5K/5.50M 2.0% where the linker said 4.22%. Worse, the whole part is mcuboot + BOTH slot0s + reserved + storage + atoc, so over_budget could never fire -- an image cannot exceed it, and the failure that does happen (overflowing one 2688 KiB window) went unflagged. resolve_budget now prefers this core's own <role>_slot0 from the SoM memory_map, matched on the same core_id.split("_")[-1] the SDK's gen_zephyr_board uses and enforcing accessible_from, and falls back to mram_mb unchanged when no per-role window is declared. The RAM half of that function already resolved per core; FLASH now does too. searched-PATH message twice, 5608 characters for one benign outcome, pushing the ok: lines and the build summary below it. Both producers are deliberate (tan-cli#510 put the PATH in the message, tan-cli#283 promoted it into issues[]); they only collide in text rendering. _text_issues drops an issues[] line the per-slice recap repeats verbatim, text mode only -- the JSON envelope still carries the full entry, verified. Module size budget regenerated through scripts/regen_module_size_budget.py --reason, not hand-edited. Full suite: 17 failed, 4152 passed. All 17 are pre-existing on this Windows host -- 15 test_completion_command (slow bash spawn), 1 test_flash_command, and 1 test_monitor_command which was verified by stashing these changes and reproducing it on clean dev. Zero added. * chore(gates): recompute the module size budget after the #745 rebase Both sides of the rebase bumped function_count_budget to 256 independently; the merged tree needs 257. Regenerated through regen_module_size_budget.py --reason, not hand-edited. --------- Co-authored-by: Alp Lab AB <dev@alplab.ai>
…omments (#749) Both installers explained the two asset shapes with an aside that v0.4.1 is what `latest` resolves to. It has not been since v0.5.0; `latest` is v0.5.1, measured from install.ps1's own resolution during a clean-room install. A reader following the old comment would expect the no-argument install to take the raw-executable path. It takes the archive path. Comments only -- zero non-comment lines changed in either file, verified. The shape detection itself never depended on the claim; it asks the release which asset name it carries via the checksums.txt fetched first. Found by a documentation sweep after the recent metadata and doctor merges. docs/release-contract.md was also flagged, and deliberately NOT changed: every claim in it is about alp-sdk-vscode (SUPPORTED_CLI_VERSION, the releaseAssetForTarget map, HOSTS_WITHOUT_RELEASE_ASSET), none of which is verifiable from this checkout. Two of the proposed corrections had no supporting evidence here at all. Co-authored-by: Alp Lab AB <dev@alplab.ai>
* release: v0.6.0-rc1 Folds 42 changelog.d/ fragments into the release section and bumps the three version files to 0.6.0-rc1. The number is 0.6.0, not the 0.5.2 the version files carried: the milestone being closed is v0.6.0 with 206 closed issues, no v0.5.2 milestone exists, and the section carries a Removed block (the Rust oracle) -- pre-1.0 SemVer puts a removal in the minor, not a patch. TAN_VERSION (source of truth) : 0.6.0-rc1 python/pyproject.toml : 0.6.0rc1 npm-shim/package.json : 0.6.0-rc1 CHANGELOG.md section : ## [0.6.0-rc1] git tag : v0.6.0-rc1 versions agree Also repairs the section hierarchy: seven fragments this cycle were written with a '### Fixed -- <title>' header instead of the bullet changelog.d/README.md specifies, so the fold produced seven stray '###' sections where v0.5.1 has exactly one '### Fixed'. Demoted to '####' under the canonical heading; no content changed. * release: fold #749 into the v0.6.0-rc1 section The assembler only folds into an 'Unreleased' header, so a PR landing after the header is dated cannot be folded by the tool. Renamed back, folded, re-dated. assemble_changelog.py --require-empty rc=0 --------- Co-authored-by: Caner Alp <contact@alplab.ai>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
dev(e2f5021) tomainfor thev0.6.0-rc1tag. 131 commits.What this release is
The Rust oracle is retired —
crates/tan-core,crates/tan-cli,Cargo.tomlandCargo.lockare deleted, andpython/tan/is the implementation rather than a port. Alongside that: a workflow-injection fix inparity.yml, SHA-pinning across.github/workflows/, and a sweep through the commands that reported success while doing something else.Version
0.6.0, not the0.5.2the version files carried before #750: the milestone closed is v0.6.0 with 206 closed issues, nov0.5.2milestone exists, and the section carries a### Removedblock — pre-1.0 SemVer puts a removal in the minor.Gates
Full
python/tests, parity included, bound against alp-sdkbd8be484680cf5aa1c1ac0e8b38d84128b5a279d:Zero failures, run on the exact merged tree. #750 itself merged 31 success / 0 red.
After this
Tag
v0.6.0-rc1onmain, which firesrelease.yml:verify-version→gates+python-gates→ four PyInstaller freezes → the published Release withchecksums.txt,envelope-contract.jsonand a provenance attestation.The tag publishes
prerelease: true/make_latest: false, soinstall.shandinstall.ps1keep resolvinglatesttov0.5.1— RC testers install by hand.SUPPORTED_CLI_VERSIONinalp-sdk-vscodedeliberately does not move for an RC; that travels with GA.A lean release body is written and will be applied as soon as the page exists — the CHANGELOG auto-slice is 4670 lines.
Not in this cut
#58 (
feat(model)edge-AI surface) isCONFLICTINGand excluded by maintainer direction.