From e5eacf0b64e0b50b3e324c88c6e4ac204bbbdce0 Mon Sep 17 00:00:00 2001 From: Caner Alp <109098482+alpCaner@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:54:35 +0000 Subject: [PATCH 01/10] fix(bootstrap): close the ETXTBSY race in both inline shim writes (#318) The two ensure_venv_* tests write their stand-in interpreter with std::fs::write and let ensure_venv spawn it immediately. cargo test runs tests in parallel threads, so a sibling test's fork can still hold a write fd on the just-written file and the spawn fails ETXTBSY. ensure_venv fails CLOSED on a probe error and recreates the venv, so the assertion that the venv was REUSED then panics -- a required check (test (ubuntu-latest)) going red with no code change, at a measured 1 in 20. wait_until_spawnable already exists in this file for exactly this reason: 204c3a0 added it to exit_code_shim to cure the same race for a sibling test, but did not touch these two, which still write their shim inline. crates/ is frozen for feature work; this is the deliberate test-hermeticity exception 204c3a0 set the precedent for. Zero production delta. Measured post-fix: 20/20 green over a full-binary loop. --- crates/tan-cli/src/commands/bootstrap/steps.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/tan-cli/src/commands/bootstrap/steps.rs b/crates/tan-cli/src/commands/bootstrap/steps.rs index a857920a..3e807667 100644 --- a/crates/tan-cli/src/commands/bootstrap/steps.rs +++ b/crates/tan-cli/src/commands/bootstrap/steps.rs @@ -870,6 +870,11 @@ mod tests { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&python_path, std::fs::Permissions::from_mode(0o755)).unwrap(); } + // #250/#318: absorb the same transient ETXTBSY `exit_code_shim` guards + // against -- this shim is spawned by `probe_venv_pip` inside + // `ensure_venv` below, moments after the `write` above, and is exactly + // as exposed to a still-open write fd from a concurrent sibling test. + wait_until_spawnable(&python_path); assert!( python_path.is_file(), "the broken interpreter must exist first" @@ -926,6 +931,12 @@ mod tests { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&python_path, std::fs::Permissions::from_mode(0o755)).unwrap(); } + // #250/#318: same fix as `exit_code_shim` -- `probe_venv_pip` spawns + // this shim inside `ensure_venv` below, and a still-open write fd + // inherited by a concurrent sibling test's fork can make that spawn + // see a transient ETXTBSY, fail closed, and wrongly recreate the venv + // `/bin/false` was meant to prove untouched (tan-cli#318). + wait_until_spawnable(&python_path); let facts = fallback_facts((3, 10)); let ws = Workspace { From 1af6e64977f984de0b6566783dad9016a6801260 Mon Sep 17 00:00:00 2001 From: Caner Alp <109098482+alpCaner@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:54:48 +0000 Subject: [PATCH 02/10] fix(flash): refuse a non-raw slot0 artefact, and split the DPIDR refusal causes (#311, #312) plan_alif_mram_jlink emitted `loadbin ` uncondi- tionally, with no is_raw_bin guard -- unlike its sibling plan_swd_probe, which branches loadbin-for-.bin vs loadfile-for-ELF. A slot0-linked artefact that is not a raw .bin would have had its ELF headers written into on-die MRAM at the load address. It now raises FlashPlanError naming the artefact and pointing at zephyr.bin. Deliberately a refusal rather than plan_swd_probe's loadfile fallback: a loadfile here would silently ignore slot0_load_address, which is the worse failure. Latent today -- no in-tree board.yaml sets slot0_load_address. Separately, the Flow D DPIDR preflight conflated two causes. A banner reporting a DIFFERENT DP ID (a real wrong-board or probe-selection problem) and a banner reporting no DP ID at all (connect failed outright, typically the J-Link still re-enumerating after a prior JLinkExe close) both got: Check the probe selection (flash_args.jlink_serial) and the wiring. The second now says the probe reported no DP ID at all and advises a retry. Detection is conservative and falls back to the existing generic message when it cannot tell -- a widened refusal that claimed the wiring was fine would be worse than the ambiguity. Both cases still refuse to write MRAM; this is a diagnostic split only. Neither backend has a v0.4.1 oracle counterpart -- alif_mram_jlink is new -- so there is no envelope to diff against here. --- python/tan/commands/flash_cmd.py | 71 ++++++++++++ python/tan/core/flash_plan.py | 18 +++ python/tests/commands/test_flash_command.py | 122 ++++++++++++++++++++ 3 files changed, 211 insertions(+) diff --git a/python/tan/commands/flash_cmd.py b/python/tan/commands/flash_cmd.py index bbea811d..c2994a5f 100644 --- a/python/tan/commands/flash_cmd.py +++ b/python/tan/commands/flash_cmd.py @@ -44,6 +44,7 @@ import functools import os +import re import subprocess import sys import tempfile @@ -1036,6 +1037,30 @@ def _flow_d_preflight( f"({outcome.stderr.strip() or 'probe silent'}); refusing to write MRAM " "without confirming which board is attached." ) + # `expected` is confirmed absent (checked above) -- but "absent" covers two + # measurably different banners (tan-cli#312): a connect that DID reach a + # board and reported some OTHER SW-DP ID (a real wrong-board / wiring / + # probe-selection problem), and a connect that reported no ID at all + # (measured on the rc3 bench: the probe still re-enumerating a few seconds + # after a prior `JLinkExe` close -- same probe, same cable, same + # `jlink_serial`, and nothing wrong with either). Both used to get the + # SAME wiring-and-jlink_serial sentence, which sent a user re-checking + # cables that were never the problem. + # + # Conservative on purpose: the "no ID at all" message below asserts the + # wiring is FINE, so it is only used when BOTH signals agree -- no + # DP-ID-shaped token anywhere in the banner, AND the banner carries + # SEGGER's own "the probe itself refused" wording. Anything the detector + # cannot place that confidently keeps the original sentence rather than + # guessing the wiring is innocent. + if not _dp_id_reported(banner) and _connect_failed_outright(banner): + return ( + f"{FLOW_D_METHOD}: the read-only DPIDR preflight's connect reported no " + f"SW-DP ID at all (expected {expected}) -- refusing to write MRAM to an " + "unidentified board. This looks like the J-Link probe still " + "re-enumerating after a previous JLinkExe session closed, not a wiring " + "or probe-selection problem -- wait a couple of seconds and retry." + ) return ( f"{FLOW_D_METHOD}: expected SW-DP IDR {expected} was not reported on connect " "-- refusing to write MRAM to an unidentified board. Check the probe " @@ -1054,6 +1079,52 @@ def _hex_in(expected: str, haystack: str) -> bool: return needle in haystack.lower().replace("0x", "") +#: SEGGER's own wording for a successful SWD connect that read AN id, whatever +#: it turned out to be -- "Found SW-DP with ID 0x........" / "DPIDR: 0x........". +#: Matched loosely on purpose: what this distinguishes is "a real board +#: answered with a different identity" from "nothing answered", not the exact +#: firmware/DLL version's phrasing. +_DP_ID_RE = re.compile(r"(?:with\s+ID|DPIDR)\s*:?\s*0x[0-9A-Fa-f]+", re.IGNORECASE) + +#: SEGGER's own wording for the PROBE itself refusing the connection outright +#: -- measured verbatim on the rc3 bench run: "Connecting to J-Link ...FAILED: +#: Cannot connect to the probe/programmer." (tan-cli#312). Deliberately NOT a +#: bare `FAILED`/`Cannot connect` match (that was the tan-cli#312 review +#: finding): JLinkExe prints "FAILED" in many contexts, and "Cannot connect" +#: alone also fires on a TARGET-level refusal -- see `_CONNECT_FAILED_TARGET_RE` +#: below -- which is a real wiring/probe-selection problem, not a re-enumerating +#: probe. +_CONNECT_FAILED_RE = re.compile(r"Cannot connect to the probe/programmer", re.IGNORECASE) + +#: SEGGER's own wording for a TARGET-level connect refusal -- "Cannot connect +#: to target." (unplugged SWD ribbon, unpowered board) or "Cannot connect to +#: J-Link." (a probe that IS reachable via USB but refuses the requested +#: `jlink_serial`). Both are genuine wiring/probe-selection problems, so their +#: presence forces `_connect_failed_outright` to False even alongside the +#: probe-level phrase above -- asserting "wiring is fine" here would be the +#: false negative tan-cli#312's review flagged (measured against a real +#: unplugged-ribbon and a real unpowered-target banner). +_CONNECT_FAILED_TARGET_RE = re.compile(r"Cannot connect to (?:target|J-Link)\b", re.IGNORECASE) + + +def _dp_id_reported(banner: str) -> bool: + """Whether the banner names ANY SW-DP ID -- not whether it matches + `expected` (the caller already ruled that out via `_hex_in`), only whether + a connect got far enough to read one at all.""" + return _DP_ID_RE.search(banner) is not None + + +def _connect_failed_outright(banner: str) -> bool: + """Whether the banner carries SEGGER's own wording for the PROBE itself + refusing the connection (still re-enumerating, no board reachable at all), + as opposed to a TARGET-level refusal -- a real wiring/probe-selection + problem that must keep the original remediation, not the re-enumeration + one.""" + if _CONNECT_FAILED_TARGET_RE.search(banner) is not None: + return False + return _CONNECT_FAILED_RE.search(banner) is not None + + def _is_file(path: str) -> bool: """`Path::is_file`, incapable of raising -- it is called on manifest-supplied strings, which may hold a NUL byte or overlong component.""" diff --git a/python/tan/core/flash_plan.py b/python/tan/core/flash_plan.py index e4313f19..d78beac3 100644 --- a/python/tan/core/flash_plan.py +++ b/python/tan/core/flash_plan.py @@ -1304,6 +1304,24 @@ def plan_alif_mram_jlink(inp: FlashInputs, which: Callable[[str], bool]) -> Flas ) if app_address is not None: validate_address(app_address, "slot0_load_address") + # The mramxip shape `loadbin`s the app blob at an explicit MRAM + # address (see below) -- correct ONLY for a raw `.bin`. `loadbin`ing + # anything else (e.g. `zephyr.elf`) at that address writes the + # artefact's own headers into MRAM instead of the app image + # (tan-cli#311). Unlike `plan_swd_probe`'s ELF/HEX fallback to + # `loadfile`, there is no fallback here: `loadfile` ignores + # `slot0_load_address` entirely, which would silently place the app + # wherever the ELF's own load addresses say rather than where this + # flow demands -- a refusal is the safer failure. + if not is_raw_bin(inp.artefact): + raise FlashPlanError( + f"{FLOW_D_METHOD}: flash_args.slot0_load_address is set but the " + f"artefact {inp.artefact} is not a raw .bin -- refusing to loadbin " + "it at slot0_load_address, which would write the artefact's own " + "headers into MRAM instead of the app image. Point the build's " + "output_artefact at the slot0-linked zephyr.bin for the mramxip " + "shape." + ) atoc = fa_str(fa, "atoc") atoc_address = fa_str_checked(fa, "atoc_address", True) diff --git a/python/tests/commands/test_flash_command.py b/python/tests/commands/test_flash_command.py index 2d419f6c..0e1b86a2 100644 --- a/python/tests/commands/test_flash_command.py +++ b/python/tests/commands/test_flash_command.py @@ -887,6 +887,27 @@ def test_flow_d_present_but_null_or_empty_slot0_load_address_refuses(bad_value): assert "slot0_load_address" in str(raised.value) +def test_flow_d_mramxip_shape_refuses_a_non_raw_bin_artefact(): + """A slot0-linked artefact that is not a raw `.bin` (e.g. `zephyr.elf`) must + never reach `loadbin ... slot0_load_address` -- that writes the artefact's + own headers into MRAM at the load address instead of the app image + (tan-cli#311). Unlike `plan_swd_probe`'s ELF/HEX fallback to `loadfile`, + there is no fallback here: `loadfile` would silently ignore + `slot0_load_address`, which is a worse failure than a refusal.""" + args = {**FLOW_D_ARGS, "confirm": True} + with pytest.raises(FlashPlanError) as raised: + plan_alif_mram_jlink( + FlashInputs( + artefact="/build/zephyr/zephyr.elf", flash_args=args, core_id="m", sku="S" + ), + lambda t: True, + ) + message = str(raised.value) + assert "zephyr.elf" in message + assert "zephyr.bin" in message + assert "slot0_load_address" in message + + def test_flow_d_holds_no_part_number_of_its_own(): """The whole point of resolving the profile from metadata. `alif`, `AE822...` and the MRAM addresses must appear NOWHERE in the module -- not as @@ -1003,6 +1024,107 @@ def test_flow_d_preflight_present_but_null_or_empty_jlink_device_refuses(bad_val assert "jlink_device" in str(raised.value) +def _flow_d_preflight_inputs(): + args = {**FLOW_D_ARGS, "expect_dpidr": "0x4C013477", "jlink_device": "Generic-Attach"} + return FlashInputs(artefact="/b/z.bin", flash_args=args, core_id="m", sku="S") + + +def _stub_flow_d_probe(monkeypatch, stdout: str, stderr: str = "", success: bool = True): + """Make `_flow_d_preflight` reach a fake connect banner without a real + J-Link on PATH or an actual spawn -- `_tool_available`/ + `_programs_resolved_in_venv` are the tool-gate and venv-resolution steps + ahead of the spawn, neither of which this test cares about.""" + monkeypatch.setattr(flash_cmd, "_tool_available", lambda *_a, **_k: True) + monkeypatch.setattr(flash_cmd, "_programs_resolved_in_venv", lambda argv, _venv_bin: argv) + monkeypatch.setattr( + flash_cmd, + "_spawn_jlink", + lambda *_a, **_k: flash_cmd._Outcome(success=success, stdout=stdout, stderr=stderr), + ) + + +def test_flow_d_preflight_a_different_reported_dp_id_keeps_the_wiring_message(monkeypatch): + """tan-cli#312, case (a): the probe DID connect and reported a real, just + different, SW-DP ID -- a genuine wrong-board / wiring / probe-selection + problem, so the original remediation stands unchanged.""" + _stub_flow_d_probe( + monkeypatch, + stdout="Connecting to target via SWD\nFound SW-DP with ID 0x2BA01477\n", + ) + message = flash_cmd._flow_d_preflight(_flow_d_preflight_inputs()) + assert message is not None + assert "Check the probe selection (flash_args.jlink_serial) and the wiring" in message + assert "re-enumerat" not in message + + +def test_flow_d_preflight_no_dp_id_at_all_gets_the_re_enumeration_message(monkeypatch): + """tan-cli#312, case (b): measured verbatim on the rc3 bench run -- the + probe refused the connect outright, mid re-enumeration after a prior + `JLinkExe` close, and reported no SW-DP ID whatsoever. This must NOT get + the wiring/jlink_serial sentence: nothing was wrong with either.""" + _stub_flow_d_probe( + monkeypatch, + stdout="Connecting to J-Link ...FAILED: Cannot connect to the probe/programmer.\n", + stderr="J-Link uptime (since boot): 0d 00h 00m 01s\n", + success=False, + ) + message = flash_cmd._flow_d_preflight(_flow_d_preflight_inputs()) + assert message is not None + assert "re-enumerat" in message + assert "Check the probe selection" not in message + assert "0x4C013477" in message + + +def test_flow_d_preflight_an_unrecognised_banner_falls_back_to_the_wiring_message(monkeypatch): + """Conservative by design (tan-cli#312): a banner with neither a + recognisable DP-ID token NOR SEGGER's own connect-refused wording is not + confidently "just re-enumerating" -- the detector must not guess the + wiring is fine, so this keeps the original sentence.""" + _stub_flow_d_probe(monkeypatch, stdout="some unrecognised probe banner\n") + message = flash_cmd._flow_d_preflight(_flow_d_preflight_inputs()) + assert message is not None + assert "Check the probe selection (flash_args.jlink_serial) and the wiring" in message + assert "re-enumerat" not in message + + +def test_flow_d_preflight_a_target_level_cannot_connect_keeps_the_wiring_message(monkeypatch): + """tan-cli#312 review finding: an unplugged SWD ribbon / no board present + produces "Cannot connect to target." -- a genuine wiring problem, not a + re-enumerating probe. This must NOT get the "not a wiring... problem" + re-enumeration message: on a bench that would turn a real unplugged cable + into an infinite wait-and-retry loop instead of the correct remediation.""" + _stub_flow_d_probe( + monkeypatch, + stdout=( + "Connecting to target via SWD\n" + "InitTarget() start\n" + "InitTarget() end\n" + "Cannot connect to target.\n" + ), + success=False, + ) + message = flash_cmd._flow_d_preflight(_flow_d_preflight_inputs()) + assert message is not None + assert "Check the probe selection (flash_args.jlink_serial) and the wiring" in message + assert "re-enumerat" not in message + + +def test_flow_d_preflight_a_wrong_jlink_serial_keeps_the_wiring_message(monkeypatch): + """tan-cli#312 review finding: a probe that IS reachable via USB but + refuses the requested `flash_args.jlink_serial` prints "Cannot connect to + J-Link." -- a real probe-selection problem, so this keeps the original + wiring/`jlink_serial` remediation rather than the re-enumeration message.""" + _stub_flow_d_probe( + monkeypatch, + stdout="Connecting to J-Link via USB...FAILED: Cannot connect to J-Link.\n", + success=False, + ) + message = flash_cmd._flow_d_preflight(_flow_d_preflight_inputs()) + assert message is not None + assert "Check the probe selection (flash_args.jlink_serial) and the wiring" in message + assert "re-enumerat" not in message + + def test_flow_d_needs_jlink_on_path_for_a_real_run(): with pytest.raises(FlashPlanError) as raised: plan_alif_mram_jlink(flow_d_inputs(confirm=True), lambda t: False) From 5d03b86d1e615d5c855a6ae6a0910c69b591df30 Mon Sep 17 00:00:00 2001 From: Caner Alp <109098482+alpCaner@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:55:08 +0000 Subject: [PATCH 03/10] fix(build): fill the Zephyr env gap, and refuse an os:zephyr slice that never loaded Zephyr (#308, #309) Two oracle-parity divergences in the executor, where the v0.4.1 oracle does the right thing and the port did not. #308: build_cmd.py passed gap_fillers=() with a literal '# NOT YET PORTED', and execute.py starts from dict(os.environ). ADR-0020 plans never carry ZEPHYR_BASE, so a stale ambient $ZEPHYR_BASE in the user's shell beat the west workspace tan had itself just resolved. The oracle fills it via zephyr_env_overrides; the resolved value already existed on this side as west_workspace_dir. Ported as pure logic into python/tan/core/zephyr_env.py rather than into the command module. EXTRA_ZEPHYR_MODULES joins with ';' on every platform -- it is a CMake list that Zephyr's own zephyr_module.py splits on ';' regardless of host OS, and joining it with ':' on Linux breaks west build configure with 'is not a valid zephyr module'. The values flow through the existing sep_for_key machinery rather than re-implementing the join, and assemble_slice_env's seed-then-append still extends an inherited PYTHONPATH instead of replacing it. Both are covered by tests. #309: a CMakeLists.txt that never calls find_package(Zephyr ...) still configures and links fine under `west build -b ` -- CMake only emits a dev warning about the missing project() call -- so a real exit code 0 was not sufficient evidence and a core declared `os: zephyr` could produce a plain host binary and be reported [+] ok. The board name is never even validated, because nothing loaded the code that would validate it. zephyr_boilerplate_loaded / dir_shows_zephyr are ported into build/ manifest.py and applied in execute.py's status assembly, checked only on an otherwise-successful slice and skipped when the slice redirects west's build dir, where the evidence lives somewhere this cannot see. It looks one level below build/ as well, because --sysbuild nests the real per-image Zephyr builds under its own superbuild and only the nested build carries a signal. The guard exposed 13 tests whose plan fixtures declared backend zephyr while dispatching a trivial stand-in tool that produces no Zephyr evidence. Twelve are relabelled to a non-zephyr backend after checking per test that no assertion touched zephyr artefact resolution; the one test genuinely about west's own build-dir resolution instead has its stand-in west script create a real zephyr/ directory, keeping it on the live code path. The separate question of whether `tan init --template minimal-app` should emit a different CMake shape is a maintainer decision and is untouched here; the scaffold is byte-identical to the oracle's. --- python/tan/commands/build/execute.py | 92 +++++++-- python/tan/commands/build/manifest.py | 67 ++++++- python/tan/commands/build_cmd.py | 14 +- python/tan/core/zephyr_env.py | 73 +++++++ python/tests/commands/test_build_command.py | 33 +++- .../test_build_manifest_zephyr_guard.py | 96 +++++++++ python/tests/commands/test_build_streaming.py | 6 +- python/tests/commands/test_execute.py | 35 +++- .../tests/commands/test_execute_zephyr_env.py | 187 ++++++++++++++++++ .../commands/test_execute_zephyr_guard.py | 151 ++++++++++++++ python/tests/core/test_zephyr_env.py | 89 +++++++++ 11 files changed, 803 insertions(+), 40 deletions(-) create mode 100644 python/tan/core/zephyr_env.py create mode 100644 python/tests/commands/test_build_manifest_zephyr_guard.py create mode 100644 python/tests/commands/test_execute_zephyr_env.py create mode 100644 python/tests/commands/test_execute_zephyr_guard.py create mode 100644 python/tests/core/test_zephyr_env.py diff --git a/python/tan/commands/build/execute.py b/python/tan/commands/build/execute.py index 31d39725..a945b953 100644 --- a/python/tan/commands/build/execute.py +++ b/python/tan/commands/build/execute.py @@ -6,7 +6,7 @@ mod.rs`, trimmed to this port's current scope: no `tan build --pristine` manual override (`force_pristine` in the Rust oracle -- this port's `build` command has no `--pristine` flag yet, so the automatic stamp comparison is -the only path that can ever fire), and no Zephyr-boilerplate-loaded guard. +the only path that can ever fire). What IS ported: the unknown-backend / null-command / unsafe-cwd / missing-tool skip-vs-fail policy and dispatch order, the build-dir-must-exist-before-the- tool-runs precondition, the `tool == "west"` rewrite to the workspace venv's @@ -17,8 +17,12 @@ `tan.core.venv`), the sdk-switch-pristine guard (issue #52: wipe a slice's build dir before dispatch when it was configured against a different SDK root than this run resolved, then re-stamp it -- see -[_maybe_pristine_stale_sdk_build_dir]), and (see [`last_manifest_write`]) the -post-build `system-manifest.yaml` write. +[_maybe_pristine_stale_sdk_build_dir]), (tan-cli#309, upstream tan-cli #97) +the Zephyr-boilerplate-loaded guard -- an `os: zephyr` slice that exits 0 +without ever loading Zephyr's CMake boilerplate (`tan.commands.build. +manifest.zephyr_boilerplate_loaded`) is reported `failed`, not `ok`, since a +real exit code alone is not evidence the build produced firmware -- and (see +[`last_manifest_write`]) the post-build `system-manifest.yaml` write. **tan-cli#307, a DELIBERATE divergence from the frozen Rust oracle.** `crates/` is frozen (`docs/ROADMAP.md`'s standing rule), and the oracle's own @@ -61,6 +65,7 @@ resolve_zephyr_artefact, write_post_build_manifest, write_sdk_stamp, + zephyr_boilerplate_loaded, ) from tan.commands.build.materialise import MaterialiseError, confine_to_build_root from tan.core.plan_exec import ( @@ -73,6 +78,7 @@ ) from tan.core.system_manifest import SliceRunResult from tan.core.venv import west_program, west_workspace_dir, with_venv_on_path +from tan.core.zephyr_env import zephyr_env_overrides from tan.envelope import Issue if os.name != "nt": @@ -498,9 +504,17 @@ def execute_slices( # resolves (CI, an activated venv, the contract harness) -- every west # slice below then keeps its old cwd, matching the pre-fix behaviour # exactly (see [`_pin_west_workspace`]). - workspace_dir = west_workspace_dir( - str(build_root), Path(sdk_root) if sdk_root is not None else None - ) + sdk_root_path = Path(sdk_root) if sdk_root is not None else None + workspace_dir = west_workspace_dir(str(build_root), sdk_root_path) + # tan-cli#308: port of the oracle's `resolve_zephyr_base` -- the + # workspace's own `zephyr/` checkout, filtered to a real directory so a + # `workspace_dir` that resolved but was never `west update`d (no + # `zephyr/` yet) does not hand `west` a `ZEPHYR_BASE` that does not + # exist. `None` propagates through [`zephyr_env_overrides`] as "nothing + # to fill", matching every other `workspace_dir` consumer's fallback. + zephyr_base = workspace_dir / "zephyr" if workspace_dir is not None else None + if zephyr_base is not None and not zephyr_base.is_dir(): + zephyr_base = None for sl in plan.slices: if sl.backend not in KNOWN_BACKENDS: @@ -583,8 +597,26 @@ def execute_slices( ) ) + # tan-cli#308: the zephyr gap-fillers are computed PER SLICE (not + # once for the whole run, unlike `workspace_dir`/`zephyr_base` + # themselves) because "plan wins" depends on THIS slice's own + # `env`/`env_append_path` -- a heterogeneous plan can have one slice + # that already pins `EXTRA_ZEPHYR_MODULES` (an SDK-emitted plan's + # `envAppendPath`) alongside one that doesn't, and the caller's + # `gap_fillers` merge (`assemble_slice_env`) OVERWRITES a key + # unconditionally -- computing this once, outside the loop, would + # silently clobber the plan's own richer module list on every slice + # that DOES pin it. + slice_gap_fillers = [ + *gap_fillers, + *zephyr_env_overrides( + zephyr_base, sdk_root_path, sl.env, sl.env_append_path, env_lookup + ), + ] env = dict(os.environ) - env.update(dict(assemble_slice_env(sl.env, sl.env_append_path, env_lookup, gap_fillers))) + env.update( + dict(assemble_slice_env(sl.env, sl.env_append_path, env_lookup, slice_gap_fillers)) + ) # tan-cli#289/#106: the venv `west` spawns nested `west`/`bitbake` # (via `alp_orchestrate`) that resolve purely via PATH -- without # this they fail to find `west` exactly like the parent process @@ -649,22 +681,60 @@ def execute_slices( ) continue + status = "succeeded" if code == 0 else "failed" + message = None if code == 0 else f"slice `{sl.core_id}` terminated with exit code: {code}" + + # tan-cli#309 (upstream tan-cli #97): a core declared `os: zephyr` + # whose CMakeLists.txt never calls `find_package(Zephyr ...)` still + # configures and links fine under `west build -b ` (CMake + # only emits a *dev* warning about the missing `project()` call), so + # a real exit code 0 is NOT sufficient evidence -- without this the + # out-of-the-box scaffold was reported `[+] ok` for a plain host + # binary with no Zephyr in it at all. Checked only on an otherwise- + # successful slice (a genuine build failure already speaks for + # itself) and skipped when the slice redirects west's own build dir + # (`-d`/`--build-dir`), where the evidence lives somewhere this + # cannot see -- the same refusal `resolve_zephyr_artefact` below + # already makes. + if ( + status == "succeeded" + and sl.backend == "zephyr" + and not build_dir_overridden(sl.command.args) + and not zephyr_boilerplate_loaded(cwd) + ): + status = "failed" + message = ( + f"core `{sl.core_id}` is declared `os: zephyr`, but the build in " + f"`{sl.command.cwd or '.'}` never loaded Zephyr (no ZEPHYR_BASE in its " + f"CMakeCache.txt and no zephyr/ output) — its CMakeLists.txt must call " + f"`find_package(Zephyr REQUIRED HINTS $ENV{{ZEPHYR_BASE}})` before `project()`; " + f"without it CMake builds a plain host binary, not firmware. Scaffold a working " + f"app with `tan init --template zephyr-app`, or point the core's `app:` at one " + f"that does." + ) + # On success, resolve the real on-disk artefact west produced so the # post-build manifest points downstream consumers (`run`/`size`/ # `flash`/`image`) at the elf that exists, not a plan-time guess. + # Gated on the FINAL `status` (after the guard above), not the raw + # exit code: a guard-failed slice has no real Zephyr artefact to + # report even though the tool itself exited 0. output_artefact, slice_build_dir = ( - resolve_zephyr_artefact(cwd, sl.command.args) if code == 0 else (None, None) + resolve_zephyr_artefact(cwd, sl.command.args) if status == "succeeded" else (None, None) ) outcomes.append( SliceOutcome( sl.core_id, - "succeeded" if code == 0 else "failed", + status, # A negative POSIX return code means the process died from a # signal -- Rust's `ExitStatus::code()` returns `None` for # that case (it has no single-integer exit code), so the - # envelope's `rc` must be null too, not the raw `-N`. + # envelope's `rc` must be null too, not the raw `-N`. Stays + # the tool's REAL exit code even when the guard above + # overrode `status` to "failed" -- `west build` really did + # exit 0, the guard is refusing the RESULT, not the exit. None if code < 0 else code, - None if code == 0 else f"slice `{sl.core_id}` terminated with exit code: {code}", + message, output_artefact, slice_build_dir, ) diff --git a/python/tan/commands/build/manifest.py b/python/tan/commands/build/manifest.py index d6b2aae0..bd526995 100644 --- a/python/tan/commands/build/manifest.py +++ b/python/tan/commands/build/manifest.py @@ -3,14 +3,14 @@ manifest.yaml` the downstream `tan run`/`tan flash`/`tan size`/`tan image` contract reads, and resolve the real on-disk `zephyr.elf` a slice produced. -Port of `crates/tan-cli/src/commands/build/execute/manifest.rs`, trimmed to -this port's current scope the same way `tan.commands.build.execute` already -is (see that module's docstring): no Zephyr-boilerplate-loaded guard. What IS +Port of `crates/tan-cli/src/commands/build/execute/manifest.rs`. What's ported: the post-build `write_post_build_manifest` seam and its two in-memory signals (`write_failed_reason` / `native_sim_target`), `resolve_zephyr_artefact`'s -default-nested-build-dir artefact resolution, and the SDK-identity stamp +default-nested-build-dir artefact resolution, the SDK-identity stamp (`sdk_stamp_path`/`read_sdk_stamp`/`write_sdk_stamp`/`cmake_cache_configured`) -the sdk-switch-pristine guard in `execute.py` reads and writes (issue #52). +the sdk-switch-pristine guard in `execute.py` reads and writes (issue #52), +and (tan-cli#309) the Zephyr-boilerplate-loaded guard (tan-cli #97 upstream) +[`zephyr_boilerplate_loaded`] -- `execute.py`'s status assembly applies it. **Why `sdk_root`/`board_yaml` stay ACCEPTED overrides here rather than always required.** The Rust oracle's `write_post_build_manifest` takes a @@ -51,6 +51,7 @@ "sdk_stamp_path", "write_post_build_manifest", "write_sdk_stamp", + "zephyr_boilerplate_loaded", ] @@ -318,3 +319,59 @@ def cmake_cache_configured(slice_cwd: Path) -> bool: `sdk_stamp_action` needs before it treats a missing stamp as stale. Port of `manifest.rs::cmake_cache_configured`.""" return (slice_cwd / "build" / "CMakeCache.txt").is_file() + + +def _dir_shows_zephyr(directory: Path) -> bool: + """The two per-directory signals behind [`zephyr_boilerplate_loaded`]: + a `ZEPHYR_BASE:` entry in `directory`'s own `CMakeCache.txt` (the primary + signal -- what `find_package(Zephyr)` caches, verified against a real + `/build/CMakeCache.txt`; a plain host configure never writes one), + OR a `zephyr/` subdirectory (Zephyr's boilerplate binary dir, kept as an + OR fallback so the guard can only ever fail a build it is SURE about). + Port of `manifest.rs::dir_shows_zephyr`, whose `std::fs::read_to_string` + folds invalid-UTF-8 into the SAME `io::Error` a missing file raises + (`.is_ok_and(...)` then just falls through to the `zephyr/` fallback); + `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so `except + OSError` alone let a non-UTF-8 `CMakeCache.txt` escape this function as + an uncaught exception -- the same lesson `read_sdk_stamp` above already + records for the sibling `.tan-sdk-root` read.""" + try: + cache = (directory / "CMakeCache.txt").read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + cache = "" + if any(line.startswith("ZEPHYR_BASE:") for line in cache.splitlines()): + return True + return (directory / "zephyr").is_dir() + + +def zephyr_boilerplate_loaded(slice_cwd: Path) -> bool: + """tan-cli#309 (upstream tan-cli #97): whether this slice's build dir + shows that Zephyr's CMake boilerplate actually ran -- the signal behind + the `os: zephyr` guard `execute.py`'s status assembly applies. + + The reported defect: a project whose `CMakeLists.txt` never calls + `find_package(Zephyr ...)` still configures and links fine under `west + build -b ` (CMake only emits a *dev* warning about the missing + `project()` call), so a core declared `os: zephyr` produced a host + binary and the executor reported it `[+] ok`. The board name is never + even validated, because nothing loaded the code that would validate it. + + Checked one level down too (not just `/build` itself), + because `--sysbuild` nests the real per-image Zephyr builds one + directory deeper under its own superbuild -- Zephyr's own + `share/sysbuild/CMakeLists.txt` calls `find_package(Sysbuild ...)`, not + `find_package(Zephyr)`, so a sysbuild top level carries neither signal + and only the nested per-image build does. One level is enough (sysbuild + nests per-image, not recursively). + + Callers must skip this check when [`build_dir_overridden`] -- west then + wrote somewhere this can't see, the same refusal [`resolve_zephyr_artefact`] + already makes. Port of `manifest.rs::zephyr_boilerplate_loaded`.""" + build = slice_cwd / "build" + if _dir_shows_zephyr(build): + return True + try: + children = [p for p in build.iterdir() if p.is_dir()] + except OSError: + return False + return any(_dir_shows_zephyr(child) for child in children) diff --git a/python/tan/commands/build_cmd.py b/python/tan/commands/build_cmd.py index 98f1f573..d558e181 100644 --- a/python/tan/commands/build_cmd.py +++ b/python/tan/commands/build_cmd.py @@ -805,12 +805,14 @@ def _dispatch( replace(plan, slices=runnable), build_root=build_root, env_lookup=os.environ.get, - # NOT YET PORTED: Rust fills ZEPHYR_BASE and EXTRA_ZEPHYR_MODULES - # here from the resolved west workspace, so `west build -b - # ` finds the SDK's boards without the user wiring - # -DEXTRA_ZEPHYR_MODULES. Plans emitted by the SDK carry both on - # the slice's own envAppendPath, so this is a gap only for a host - # relying on the CLI to fill them. + # tan-cli#308: no build_cmd.py-level gap fillers of our own -- + # `execute_slices` fills ZEPHYR_BASE/EXTRA_ZEPHYR_MODULES + # itself, PER SLICE, from the west workspace it resolves + # internally (`tan.core.zephyr_env.zephyr_env_overrides`), + # exactly where the Rust oracle's own `execute_slices` + # computes them (`execute/mod.rs`, inside its own per-slice + # loop) -- not from an outer caller. This parameter stays for + # a caller-supplied override this port has none of yet. gap_fillers=(), on_output=heartbeat, sdk_root=sdk_root, diff --git a/python/tan/core/zephyr_env.py b/python/tan/core/zephyr_env.py new file mode 100644 index 00000000..be0c7683 --- /dev/null +++ b/python/tan/core/zephyr_env.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Consumer-mechanism env gap-filler for `tan build`'s native executor: the +`ZEPHYR_BASE` / `EXTRA_ZEPHYR_MODULES` keys the plan deliberately does NOT +carry, filled in only when the plan didn't pin them ("plan wins / CLI fills +gaps"). Port of `zephyr_env_overrides`, `crates/tan-cli/src/commands/build/ +execute/env.rs` -- confirmed against the compiled Rust unit tests in that +module (`cargo test -p alp-tan-cli --bin tan commands::build::execute::env::`, +all 5 passing) since the oracle binary's own `--plan-from` implies `--plan` +(v0.4.1 limitation, `build_cmd.py`'s own docstring) and so cannot be driven to +dispatch a synthetic plan end to end without a real `alp_orchestrate.py` +emission. + +tan-cli#308: `ZEPHYR_BASE` is per ADR-0020 never carried by the plan at all -- +it is pure consumer mechanism, always hand-derived from the west workspace +`tan` itself resolved -- so a stale ambient `$ZEPHYR_BASE` left over from a +`source zephyr-env.sh` (or an older `tan bootstrap` next-steps block, before +tan-cli#301) must not silently win for the spawned build child.""" +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from tan.core.plan_exec import apply_env_append + + +def zephyr_env_overrides( + zephyr_base: Path | None, + sdk_root: Path | None, + slice_env: dict[str, str], + env_append_path: dict[str, list[str]], + inherited: Callable[[str], str | None], +) -> list[tuple[str, str]]: + """Consumer-mechanism env the plan deliberately does NOT carry, filled in + as a gap-filler so `tan build` runs a plan slice with no manual setup: + + * `ZEPHYR_BASE` -- the resolved workspace's zephyr. Per ADR-0020 the plan + never emits this; it is pure consumer mechanism, always hand-derived. + * `EXTRA_ZEPHYR_MODULES` -- the alp-sdk checkout, so `west build -b + ` finds the SDK's boards. This now comes FROM the plan's + `env_append_path` for an SDK-emitted plan; the hand-derived value here + is only a FALLBACK for a plan that carries neither the slice-env pin + nor the `env_append_path` entry (plan wins / CLI fills gaps). + + Never overrides a key THIS slice's env pins. + + `inherited` is the parent-process env lookup (the same one the caller + already threads through to `assemble_slice_env` for `env_append_path` + seeding). The `EXTRA_ZEPHYR_MODULES` gap-filler must not just return the + bare SDK root: the caller's own gap-filler merge OVERWRITES rather than + appends (see `assemble_slice_env`'s docstring), so returning only the SDK + root here would silently replace a developer's own + `export EXTRA_ZEPHYR_MODULES=/my/module` with just the SDK root on any + plan that doesn't itself pin the key. Seed from `inherited` and append the + SDK root via the same `apply_env_append` (per-key separator, de-dup) the + plan-driven path uses, so the two paths agree on whether an inherited + value survives.""" + out: list[tuple[str, str]] = [] + if "ZEPHYR_BASE" not in slice_env and zephyr_base is not None: + out.append(("ZEPHYR_BASE", str(zephyr_base))) + + # Plan wins: skip the hand-derived value when the plan carries + # EXTRA_ZEPHYR_MODULES (as a slice-env pin or an env_append_path entry). + if "EXTRA_ZEPHYR_MODULES" not in slice_env and "EXTRA_ZEPHYR_MODULES" not in env_append_path: + if sdk_root is not None: + sdk = str(sdk_root) + base: list[tuple[str, str]] = [] + inherited_value = inherited("EXTRA_ZEPHYR_MODULES") + if inherited_value: + base.append(("EXTRA_ZEPHYR_MODULES", inherited_value)) + apply_env_append(base, {"EXTRA_ZEPHYR_MODULES": [sdk]}) + if base: + out.append(("EXTRA_ZEPHYR_MODULES", base[0][1])) + return out diff --git a/python/tests/commands/test_build_command.py b/python/tests/commands/test_build_command.py index 4ecdd12f..085af6f9 100644 --- a/python/tests/commands/test_build_command.py +++ b/python/tests/commands/test_build_command.py @@ -124,15 +124,30 @@ def two_slice_plan(probe_args): """Two slices: one that really runs (the artefact probe) and one carrying ``command: null`` plus its matching ``warnings[]`` entry -- I-11's shape. Slice order is `sorted(coreId)` as the SDK emits it (I-06); `aaa_probe` - sorts first, so the probe IS the first dispatch.""" + sorts first, so the probe IS the first dispatch. + + Backend is ``baremetal``, not ``zephyr``: this fixture is dispatch/policy/ + envelope scaffolding across the whole file, and the probe tool never + loads real Zephyr CMake boilerplate -- a `zephyr` backend here would trip + the tan-cli#309 guard and report the probe slice `failed` on every case + that expects it `ok`. `backend` is the ONLY field that does that -- the + guard branches on `sl.backend` alone (`build/execute.py`), and the `cwd` + it inspects comes from `sl.command.cwd`, not from `buildDir`. `buildDir` + and `toolchain.id` were matched to it purely so the fixture does not read + as three different backends at once; both are inert here (nothing reads + `toolchain`, and `build_dir` reaches only token substitution and the + post-build manifest). The ``-zephyr`` suffix kept in the `configArtefacts` path + strings below is a separate, cosmetic naming convention -- several cases + elsewhere in this file assert those exact path literals, so they are + left as-is; it carries no Zephyr meaning of its own.""" def slice_(core_id, command, artefacts): return { "coreId": core_id, - "backend": "zephyr", - "buildDir": f"build/{core_id}-zephyr", + "backend": "baremetal", + "buildDir": f"build/{core_id}", "appDir": None, "configArtefacts": artefacts, - "toolchain": {"id": "zephyr"}, + "toolchain": {"id": "baremetal"}, "artifacts": {"elf": None}, "debug": {"console": "rtt"}, "command": command, @@ -344,11 +359,11 @@ def test_a_wholly_skipped_build_refuses_not_reports_success(project): def test_a_partial_build_where_only_some_slices_are_skipped_still_reports_ok(project): - """A user deliberately building only the Zephyr side on a host with no - Yocto toolchain must NOT need a flag: at least one slice built, so this - stays `ok: true` -- but the skipped slice(s) still land in `issues[]`, - naming their missing tool, so a consumer reading only `issues[]` can - still tell "2 of 3" from "3 of 3".""" + """A build where one slice's tool is present and runs while another + names a tool missing from the host must NOT need a flag: at least one + slice built, so this stays `ok: true` -- but the skipped slice(s) still + land in `issues[]`, naming their missing tool, so a consumer reading + only `issues[]` can still tell "2 of 3" from "3 of 3".""" plan_doc = two_slice_plan(ALL_ARTEFACTS) # Swap the null-command second slice for a real one naming a tool that # cannot exist on any host, so it takes the missing-tool branch (not the diff --git a/python/tests/commands/test_build_manifest_zephyr_guard.py b/python/tests/commands/test_build_manifest_zephyr_guard.py new file mode 100644 index 00000000..f23806a2 --- /dev/null +++ b/python/tests/commands/test_build_manifest_zephyr_guard.py @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#309 (upstream tan-cli #97): `zephyr_boilerplate_loaded` -- port of +`crates/tan-cli/src/commands/build/execute/manifest.rs`'s +`zephyr_boilerplate_loaded`/`dir_shows_zephyr`, confirmed against that +module's own compiled test suite (`cargo test -p alp-tan-cli --bin tan +native_execute_`, all 14 passing, including the three guard-specific cases: +`native_execute_refuses_a_zephyr_slice_whose_configure_never_loaded_zephyr`, +`native_execute_accepts_a_zephyr_slice_evidenced_by_the_cmake_cache`, +`native_execute_accepts_a_sysbuild_slice_evidenced_one_level_down`).""" +from tan.commands.build.manifest import zephyr_boilerplate_loaded + + +def test_false_on_a_never_configured_dir(tmp_path): + assert not zephyr_boilerplate_loaded(tmp_path) + + +def test_false_on_a_plain_host_configure_with_no_zephyr_evidence(tmp_path): + """The tan-cli #97 defect itself: a `CMakeCache.txt` exists (the tool + DID configure something) but carries no `ZEPHYR_BASE:` line and there is + no `zephyr/` output -- a plain host project, not firmware.""" + build = tmp_path / "build" + build.mkdir() + (build / "CMakeCache.txt").write_text( + "CMAKE_PROJECT_NAME:STATIC=alp_app\nCMAKE_GENERATOR:INTERNAL=Ninja\n", + encoding="utf-8", + ) + assert not zephyr_boilerplate_loaded(tmp_path) + + +def test_true_when_the_cmake_cache_carries_zephyr_base(tmp_path): + """The primary signal -- what `find_package(Zephyr)` actually caches. A + verified real Zephyr slice carries `ZEPHYR_BASE:PATH=...` and has NO + `zephyr/` directory, so this must be sufficient on its own.""" + build = tmp_path / "build" + build.mkdir() + (build / "CMakeCache.txt").write_text( + "CMAKE_PROJECT_NAME:STATIC=zephyr\nZEPHYR_BASE:PATH=/work/zephyr\n", + encoding="utf-8", + ) + assert not (build / "zephyr").exists(), "cache-only evidence, the real-world case" + assert zephyr_boilerplate_loaded(tmp_path) + + +def test_true_when_a_zephyr_output_directory_exists_with_no_cache(tmp_path): + """The OR fallback signal -- kept narrow (never promoted to primary) so + the guard can only ever fail a build it is SURE about.""" + build = tmp_path / "build" + (build / "zephyr").mkdir(parents=True) + assert zephyr_boilerplate_loaded(tmp_path) + + +def test_true_for_a_sysbuild_slice_evidenced_one_level_down(tmp_path): + """`--sysbuild` nests the real per-image Zephyr build one directory + deeper than its own superbuild top level, which carries neither signal + (`share/sysbuild/CMakeLists.txt` calls `find_package(Sysbuild ...)`, not + `find_package(Zephyr)`). Without the one-level-down look this fails a + correct V2N sysbuild build.""" + build = tmp_path / "build" + nested_image = build / "alp_app" + (nested_image / "zephyr").mkdir(parents=True) + assert not (build / "zephyr").exists() + assert not (build / "CMakeCache.txt").exists() + assert zephyr_boilerplate_loaded(tmp_path) + + +def test_false_when_the_one_level_down_look_finds_nothing_either(tmp_path): + build = tmp_path / "build" + (build / "some_other_dir").mkdir(parents=True) + (build / "some_other_dir" / "unrelated.txt").write_text("x", encoding="utf-8") + assert not zephyr_boilerplate_loaded(tmp_path) + + +def test_non_utf8_cmake_cache_falls_back_to_the_zephyr_dir_signal(tmp_path): + """Review finding (blocking): a non-UTF-8 `CMakeCache.txt` (a stray + `CMAKE_C_COMPILER:FILEPATH=/opt/caf\\xe9/gcc`-style byte is real-world -- + some toolchain paths embed Latin-1 bytes) must not raise. The Rust + oracle's `std::fs::read_to_string(...).is_ok_and(...)` folds invalid UTF-8 + into the same `io::Error` a missing file gets and falls straight through + to the `zephyr/` fallback -- it never propagates. `UnicodeDecodeError` is + a `ValueError`, not an `OSError`; `_dir_shows_zephyr`'s `except` clause + must catch both or this raises out of `execute_slices`, whose own module + docstring promises no escaping exception.""" + build = tmp_path / "build" + build.mkdir() + (build / "CMakeCache.txt").write_bytes(b"CMAKE_C_COMPILER:FILEPATH=/opt/caf\xe9/gcc\n") + (build / "zephyr").mkdir() + assert zephyr_boilerplate_loaded(tmp_path) + + +def test_non_utf8_cmake_cache_with_no_zephyr_dir_is_false_not_a_raise(tmp_path): + """Same corrupt cache, but with no `zephyr/` fallback evidence either -- + must resolve to `False` (an unproven Zephyr build), never raise.""" + build = tmp_path / "build" + build.mkdir() + (build / "CMakeCache.txt").write_bytes(b"CMAKE_C_COMPILER:FILEPATH=/opt/caf\xe9/gcc\n") + assert not zephyr_boilerplate_loaded(tmp_path) diff --git a/python/tests/commands/test_build_streaming.py b/python/tests/commands/test_build_streaming.py index 77879edd..97cdd368 100644 --- a/python/tests/commands/test_build_streaming.py +++ b/python/tests/commands/test_build_streaming.py @@ -167,7 +167,11 @@ def test_json_format_stdout_carries_no_heartbeat_bytes(project): and, spelled out for this ticket, none of the heartbeat's own vocabulary or control bytes -- proving `on_output=_Heartbeat(...)` never reaches stdout regardless of what the (disabled, non-TTY-here) heartbeat would - have printed on a real terminal.""" + have printed on a real terminal. `two_slice_plan` is already `baremetal` + (tan-cli#309: a `zephyr` backend here would trip the Zephyr-boilerplate + guard, since the probe command never produces real Zephyr CMake evidence), + which is all this test needs -- it asserts stdout framing only, and + Zephyr-ness is incidental to that.""" plan = write_plan(project, two_slice_plan(ALL_ARTEFACTS)) proc = run_tan( "build", "--plan-from", str(plan), "--execute", "--format", "json", cwd=project diff --git a/python/tests/commands/test_execute.py b/python/tests/commands/test_execute.py index 2c10c8ad..2c296df4 100644 --- a/python/tests/commands/test_execute.py +++ b/python/tests/commands/test_execute.py @@ -93,8 +93,11 @@ def _plan(command: str, backend: str = "zephyr") -> str: def test_successful_slice_reports_succeeded(tmp_path): + # backend "baremetal", not the `_plan()` default "zephyr": this is a bare + # dispatch-succeeds check, no Zephyr boilerplate on disk -- a `zephyr` + # backend here would trip the tan-cli#309 guard and report "failed". cmd = f'{{"tool": {PYTHON}, "args": ["-c", "print(1)"], "cwd": null}}' - out = execute_slices(parse_build_plan(_plan(cmd)), build_root=tmp_path, + out = execute_slices(parse_build_plan(_plan(cmd, backend="baremetal")), build_root=tmp_path, env_lookup=lambda k: None, gap_fillers=[], on_output=lambda s: None) assert out[0].status == "succeeded" assert out[0].exit_code == 0 @@ -222,7 +225,9 @@ def test_undecodable_stdout_bytes_are_replaced_not_fatal(tmp_path): ) cmd = f'{{"tool": {PYTHON}, "args": ["-c", {json.dumps(script)}], "cwd": null}}' lines = [] - out = execute_slices(parse_build_plan(_plan(cmd)), build_root=tmp_path, + # backend "baremetal": this is a stdout-decoding concern, unrelated to + # Zephyr -- see the tan-cli#309 comment on the test above. + out = execute_slices(parse_build_plan(_plan(cmd, backend="baremetal")), build_root=tmp_path, env_lookup=lambda k: None, gap_fillers=[], on_output=lines.append) assert out[0].status == "succeeded" assert lines, "expected at least one output line" @@ -391,7 +396,10 @@ def test_mismatched_sdk_stamp_wipes_and_restamps_the_build_dir(tmp_path, monkeyp _configure(slice_dir, "/sdk/v0.11.0") cmd = f'{{"tool": {PYTHON}, "args": ["-c", "pass"], "cwd": "build/c1"}}' lines = [] - out = execute_slices(parse_build_plan(_plan(cmd)), build_root=tmp_path, + # backend "baremetal": this test is about the sdk-switch-pristine wipe, + # orthogonal to Zephyr -- the `pass` command leaves no Zephyr boilerplate + # behind, which would otherwise trip the tan-cli#309 guard. + out = execute_slices(parse_build_plan(_plan(cmd, backend="baremetal")), build_root=tmp_path, env_lookup=lambda k: None, gap_fillers=[], on_output=lines.append, sdk_root="/sdk/v0.13.0") assert out[0].status == "succeeded" @@ -602,23 +610,28 @@ def test_manifest_overlay_writes_ok_and_failed_status_for_the_right_slices( fixture_manifest = ( "schema_version: 1\nhw_info:\n sku: S\nslices:\n" - "- core_id: ok_core\n os: zephyr\n status: pending\n" - "- core_id: bad_core\n os: zephyr\n status: pending\n" + "- core_id: ok_core\n os: baremetal\n status: pending\n" + "- core_id: bad_core\n os: baremetal\n status: pending\n" "ipc: []\nhelper_mcus: []\nboot_order: []\n" ) monkeypatch.setattr(planner_root, "emit", lambda *a, **k: fixture_manifest) + # backend "baremetal" on both slices, not "zephyr": this test is about + # `status` -> manifest wiring, not Zephyr artefact evidence -- a "zephyr" + # backend here would trip the tan-cli#309 guard and force ok_core's real + # exit-0 "pass" to read back as "failed" too, breaking the very + # succeeded-vs-failed distinction this test exists to pin. plan_json = f"""{{ "schemaVersion": 1, "generatedBy": "g", "boardYaml": {json.dumps(str(tmp_path / "board.yaml"))}, "sku": "S", "buildRoot": "build", "sharedArtefacts": [], "warnings": [], "executionPolicy": {{"missingTool": "skip", "nullCommand": "skip", "unknownBackend": "fail"}}, "slices": [ - {{"coreId": "ok_core", "backend": "zephyr", "buildDir": "build/ok_core", "appDir": "app", + {{"coreId": "ok_core", "backend": "baremetal", "buildDir": "build/ok_core", "appDir": "app", "configArtefacts": [], "toolchain": null, "artifacts": [], "debug": {{}}, "command": {{"tool": {PYTHON}, "args": ["-c", "pass"], "cwd": null}}, "env": {{}}, "envAppendPath": {{}}}}, - {{"coreId": "bad_core", "backend": "zephyr", "buildDir": "build/bad_core", "appDir": "app", + {{"coreId": "bad_core", "backend": "baremetal", "buildDir": "build/bad_core", "appDir": "app", "configArtefacts": [], "toolchain": null, "artifacts": [], "debug": {{}}, "command": {{"tool": {PYTHON}, "args": ["-c", "raise SystemExit(1)"], "cwd": null}}, "env": {{}}, "envAppendPath": {{}}}} @@ -897,8 +910,14 @@ def test_west_build_pins_the_resolved_workspace_over_an_ancestor_west(tmp_path, argv_probe = real_ws / "argv.txt" script = ( f"import os, sys\n" + f"args = sys.argv[1:]\n" f"open({json.dumps(str(probe))}, 'w').write(os.getcwd())\n" - f"open({json.dumps(str(argv_probe))}, 'w').write(repr(sys.argv[1:]))\n" + f"open({json.dumps(str(argv_probe))}, 'w').write(repr(args))\n" + # tan-cli#309: a real `west build` leaves Zephyr's own `zephyr/` + # output dir under the resolved `-d` build dir -- without this the + # tan-cli#309 guard (no evidence Zephyr's CMake boilerplate ran) + # would force this slice "failed" even though the stand-in exits 0. + f"os.makedirs(os.path.join(args[args.index('-d') + 1], 'zephyr'), exist_ok=True)\n" ) (real_ws / "build").write_text(script, encoding="utf-8") diff --git a/python/tests/commands/test_execute_zephyr_env.py b/python/tests/commands/test_execute_zephyr_env.py new file mode 100644 index 00000000..e3693b28 --- /dev/null +++ b/python/tests/commands/test_execute_zephyr_env.py @@ -0,0 +1,187 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#308 end to end: `execute_slices` fills `ZEPHYR_BASE`/ +`EXTRA_ZEPHYR_MODULES` for the spawned CMake/west child from the west +workspace it resolves itself (`tan.core.zephyr_env.zephyr_env_overrides`), +the same way `test_execute.py`'s own tan-cli#307 +`test_west_build_pins_the_resolved_workspace_over_an_ancestor_west` proves +the workspace-pin wiring -- a manifest-verified `.west/config` naming the +fake `sdk_root`, not a bare directory, so `west_workspace_dir` actually +resolves it rather than silently no-op'ing to `None` (the pre-fix state, +which this suite's own `test_...` below reproduces to prove the fail-before/ +pass-after ordering). + +Slices here are declared `backend: baremetal`, not `zephyr`: the gap-filler +[`zephyr_env_overrides`] itself has no backend check (neither does the Rust +oracle's own call site, `execute/mod.rs`, inside its per-slice loop with no +guard before it) -- it is applied to every slice regardless. `zephyr` would +also work, but would additionally trip the UNRELATED tan-cli#309 Zephyr- +boilerplate guard for a probe command that (deliberately, for this file's own +purpose) never produces real Zephyr CMake evidence; `test_execute_zephyr_ +guard.py` owns that guard's own coverage.""" +import json +import os +import sys +from pathlib import Path + +from tan.core.build_plan import parse_build_plan +from tan.commands.build.execute import execute_slices + +PYTHON = json.dumps(sys.executable) +SEP = os.pathsep + + +def _plan(command: str, env: str = "{}", env_append_path: str = "{}") -> str: + return f"""{{ + "schemaVersion": 1, "generatedBy": "g", "boardYaml": "/w/board.yaml", "sku": "S", + "buildRoot": "build", "sharedArtefacts": [], "warnings": [], + "executionPolicy": {{"missingTool": "skip", "nullCommand": "skip", "unknownBackend": "fail"}}, + "slices": [{{ + "coreId": "c1", "backend": "baremetal", "buildDir": "build/c1", "appDir": "app", + "configArtefacts": [], "toolchain": null, "artifacts": [], "debug": {{}}, + "command": {command}, "env": {env}, "envAppendPath": {env_append_path} + }}] + }}""" + + +def _make_workspace(tmp_path: Path) -> tuple[Path, Path, Path]: + """A manifest-verified west workspace (mirrors `test_execute.py`'s + tan-cli#307 `real_ws` fixture): `real_ws/.west/config` names + `real_ws/alp-sdk` as its manifest, and `real_ws/zephyr` stands in for the + Zephyr checkout `resolve_zephyr_base` looks for. Returns + `(real_ws, sdk_root, build_root)`.""" + real_ws = tmp_path / "real-ws" + sdk_root = real_ws / "alp-sdk" + sdk_root.mkdir(parents=True) + (real_ws / ".west").mkdir() + (real_ws / ".west" / "config").write_text("[manifest]\npath = alp-sdk\n", encoding="utf-8") + (real_ws / "zephyr").mkdir() + build_root = real_ws / "work" / "proj" + build_root.mkdir(parents=True) + return real_ws, sdk_root, build_root + + +def _probe_cmd(out_file: Path) -> str: + script = ( + "import json, os\n" + f"open({json.dumps(str(out_file))}, 'w').write(json.dumps(dict(os.environ)))\n" + ) + return json.dumps({"tool": sys.executable, "args": ["-c", script], "cwd": None}) + + +def test_fills_zephyr_base_and_extra_zephyr_modules_when_the_plan_carries_neither( + tmp_path, monkeypatch +): + """The behaviour tan-cli#308 reports missing: a plan slice with no + `ZEPHYR_BASE`/`EXTRA_ZEPHYR_MODULES` pin gets both filled from the + resolved workspace and `sdk_root`, not left to whatever the ambient + process env happens to hold. Fails before the fix (both keys silently + inherit whatever `dict(os.environ)` had -- `None`/unset in a scrubbed + test env) and passes after.""" + monkeypatch.delenv("ZEPHYR_BASE", raising=False) + monkeypatch.delenv("EXTRA_ZEPHYR_MODULES", raising=False) + real_ws, sdk_root, build_root = _make_workspace(tmp_path) + out_file = tmp_path / "env.json" + + out = execute_slices( + parse_build_plan(_plan(_probe_cmd(out_file))), + build_root=build_root, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + sdk_root=str(sdk_root), + ) + + assert out[0].status == "succeeded", out[0].message + seen = json.loads(out_file.read_text(encoding="utf-8")) + assert Path(seen["ZEPHYR_BASE"]).samefile(real_ws / "zephyr") + assert seen["EXTRA_ZEPHYR_MODULES"] == str(sdk_root) + + +def test_a_stale_ambient_zephyr_base_does_not_win_over_the_resolved_workspace( + tmp_path, monkeypatch +): + """tan-cli#308's actual reported defect: an exported `$ZEPHYR_BASE` left + over from an unrelated tree (a `source zephyr-env.sh`, or an older `tan + bootstrap` next-steps block) must not survive into the spawned child once + `tan` has resolved a real workspace of its own. `execute_slices` seeds + the child from `dict(os.environ)` first (line ~594) -- the ambient value + -- so this genuinely exercises the override, not just the gap-fill.""" + real_ws, sdk_root, build_root = _make_workspace(tmp_path) + stale = tmp_path / "stale-unrelated-zephyr" + stale.mkdir() + monkeypatch.setenv("ZEPHYR_BASE", str(stale)) + out_file = tmp_path / "env.json" + + out = execute_slices( + parse_build_plan(_plan(_probe_cmd(out_file))), + build_root=build_root, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + sdk_root=str(sdk_root), + ) + + assert out[0].status == "succeeded", out[0].message + seen = json.loads(out_file.read_text(encoding="utf-8")) + assert Path(seen["ZEPHYR_BASE"]).samefile(real_ws / "zephyr") + assert not Path(seen["ZEPHYR_BASE"]).samefile(stale) + + +def test_a_plan_pinned_extra_zephyr_modules_env_append_path_is_not_clobbered( + tmp_path, monkeypatch +): + """Plan wins: an SDK-emitted plan's own `envAppendPath.EXTRA_ZEPHYR_MODULES` + (the common case tan-cli#308's own severity note names) must survive + untouched -- not get overwritten with just the hand-derived `sdk_root`, + which would silently drop any OTHER module path the plan appended.""" + monkeypatch.delenv("EXTRA_ZEPHYR_MODULES", raising=False) + real_ws, sdk_root, build_root = _make_workspace(tmp_path) + out_file = tmp_path / "env.json" + + out = execute_slices( + parse_build_plan( + _plan( + _probe_cmd(out_file), + env_append_path='{"EXTRA_ZEPHYR_MODULES": ["/plan/other-module"]}', + ) + ), + build_root=build_root, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + sdk_root=str(sdk_root), + ) + + assert out[0].status == "succeeded", out[0].message + seen = json.loads(out_file.read_text(encoding="utf-8")) + assert seen["EXTRA_ZEPHYR_MODULES"] == "/plan/other-module" + # ZEPHYR_BASE is independent of this key -- still filled. + assert Path(seen["ZEPHYR_BASE"]).samefile(real_ws / "zephyr") + + +def test_an_inherited_pythonpath_is_still_extended_not_replaced(tmp_path, monkeypatch): + """Confirms the pre-existing "plan wins / CLI fills gaps" seeding + (`assemble_slice_env`, tan.core.plan_exec) still holds through + `execute_slices` after wiring the new zephyr gap-fillers alongside it -- + the new per-slice `slice_gap_fillers` list must not disturb the + envAppendPath-seeding path an unrelated var like `PYTHONPATH` takes.""" + real_ws, sdk_root, build_root = _make_workspace(tmp_path) + out_file = tmp_path / "env.json" + + out = execute_slices( + parse_build_plan( + _plan( + _probe_cmd(out_file), + env_append_path='{"PYTHONPATH": ["/plan/scripts"]}', + ) + ), + build_root=build_root, + env_lookup=lambda k: "/inherited/scripts" if k == "PYTHONPATH" else None, + gap_fillers=[], + on_output=lambda s: None, + sdk_root=str(sdk_root), + ) + + assert out[0].status == "succeeded", out[0].message + seen = json.loads(out_file.read_text(encoding="utf-8")) + assert seen["PYTHONPATH"] == f"/inherited/scripts{SEP}/plan/scripts" diff --git a/python/tests/commands/test_execute_zephyr_guard.py b/python/tests/commands/test_execute_zephyr_guard.py new file mode 100644 index 00000000..8f2e6fbc --- /dev/null +++ b/python/tests/commands/test_execute_zephyr_guard.py @@ -0,0 +1,151 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#309 end to end (upstream tan-cli #97): `execute_slices` must +refuse an `os: zephyr` slice whose CMake configure never loaded Zephyr's +boilerplate, whatever the tool's own exit code says -- reproduces the +reported defect (`tan init --template minimal-app` -> `tan build` reporting +`[+] ok` for a plain host binary) at the `execute_slices` layer, since the +scaffold's own CMake shape is a separate, explicitly out-of-scope fix (see +`python/tan/templates/vendored/MANIFEST.md:194-210`).""" +import json +import sys + +from tan.core.build_plan import parse_build_plan +from tan.commands.build.execute import execute_slices + +PYTHON = json.dumps(sys.executable) +_TRUE_CMD = f'{{"tool": {PYTHON}, "args": ["-c", "pass"], "cwd": "build/c1"}}' + + +def _plan(command: str, backend: str = "zephyr", args_extra: str = "") -> str: + return f"""{{ + "schemaVersion": 1, "generatedBy": "g", "boardYaml": "/w/board.yaml", "sku": "S", + "buildRoot": "build", "sharedArtefacts": [], "warnings": [], + "executionPolicy": {{"missingTool": "skip", "nullCommand": "skip", "unknownBackend": "fail"}}, + "slices": [{{ + "coreId": "c1", "backend": "{backend}", "buildDir": "build/c1", "appDir": "app", + "configArtefacts": [], "toolchain": null, "artifacts": [], "debug": {{}}, + "command": {command}, "env": {{}}, "envAppendPath": {{}} + }}] + }}""" + + +def test_a_zephyr_slice_that_exits_0_with_no_zephyr_evidence_is_reported_failed(tmp_path): + """The tan-cli#309 defect itself: exit code 0 alone must not be reported + `succeeded` for a declared `os: zephyr` core whose build dir shows no + sign Zephyr's CMake boilerplate ever ran.""" + out = execute_slices( + parse_build_plan(_plan(_TRUE_CMD)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "failed" + # The tool really did exit 0 -- the guard refuses the RESULT, not the + # exit, so `exit_code` must stay the real, honest value. + assert out[0].exit_code == 0 + assert out[0].output_artefact is None + assert out[0].build_dir is None + + +def test_the_refusal_message_matches_the_oracle_verbatim(tmp_path): + """Confirmed against the compiled oracle's own runtime string (`cargo + test -p alp-tan-cli --bin tan + native_execute_refuses_a_zephyr_slice_whose_configure_never_loaded_zephyr + -- --nocapture`), not transcribed from source alone.""" + out = execute_slices( + parse_build_plan(_plan(_TRUE_CMD)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].message == ( + "core `c1` is declared `os: zephyr`, but the build in `build/c1` never loaded " + "Zephyr (no ZEPHYR_BASE in its CMakeCache.txt and no zephyr/ output) — its " + "CMakeLists.txt must call `find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})` " + "before `project()`; without it CMake builds a plain host binary, not firmware. " + "Scaffold a working app with `tan init --template zephyr-app`, or point the " + "core's `app:` at one that does." + ) + + +def test_a_non_zephyr_backend_is_never_subject_to_the_guard(tmp_path): + out = execute_slices( + parse_build_plan(_plan(_TRUE_CMD, backend="baremetal")), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "succeeded" + + +def test_a_genuine_build_failure_is_reported_on_its_own_terms_not_the_guard(tmp_path): + """A real nonzero exit must not be swallowed into the guard's own + message -- the guard only ever fires on an otherwise-`succeeded` slice.""" + cmd = f'{{"tool": {PYTHON}, "args": ["-c", "raise SystemExit(3)"], "cwd": "build/c1"}}' + out = execute_slices( + parse_build_plan(_plan(cmd)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "failed" + assert out[0].exit_code == 3 + assert "os: zephyr" not in (out[0].message or "") + + +def test_a_real_zephyr_build_dir_is_accepted(tmp_path): + """The other half of the guard: it must not fail a REAL Zephyr build. + `ZEPHYR_BASE:` in the build dir's own `CMakeCache.txt` is the primary + signal, pinned here WITHOUT the directory fallback -- a verified real + Zephyr slice carries `ZEPHYR_BASE:PATH=...` and has no `zephyr/` dir.""" + nested = tmp_path / "build" / "c1" / "build" + nested.mkdir(parents=True) + (nested / "CMakeCache.txt").write_text( + "CMAKE_PROJECT_NAME:STATIC=zephyr\nZEPHYR_BASE:PATH=/work/zephyr\n", encoding="utf-8" + ) + out = execute_slices( + parse_build_plan(_plan(_TRUE_CMD)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "succeeded", out[0].message + + +def test_a_sysbuild_slice_evidenced_one_level_down_is_accepted(tmp_path): + """`--sysbuild` nests the real per-image Zephyr build one directory + deeper than its own superbuild top level -- without the one-level-down + look this would fail a correct V2N sysbuild build.""" + nested = tmp_path / "build" / "c1" / "build" / "alp_app" + (nested / "zephyr").mkdir(parents=True) + out = execute_slices( + parse_build_plan(_plan(_TRUE_CMD)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "succeeded", out[0].message + + +def test_the_guard_stands_down_when_the_build_dir_is_overridden(tmp_path): + """Same refusal `resolve_zephyr_artefact` and the sdk-switch wipe already + make: with `-d`/`--build-dir` west wrote somewhere this cannot see, so + there is no evidence to judge -- the guard must not fail a build it + cannot inspect.""" + cmd = json.dumps( + {"tool": sys.executable, "args": ["-c", "pass", "-d", "../elsewhere"], "cwd": "build/c1"} + ) + out = execute_slices( + parse_build_plan(_plan(cmd)), + build_root=tmp_path, + env_lookup=lambda k: None, + gap_fillers=[], + on_output=lambda s: None, + ) + assert out[0].status == "succeeded", out[0].message diff --git a/python/tests/core/test_zephyr_env.py b/python/tests/core/test_zephyr_env.py new file mode 100644 index 00000000..9f3c860b --- /dev/null +++ b/python/tests/core/test_zephyr_env.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#308: `zephyr_env_overrides` -- port of `crates/tan-cli/src/ +commands/build/execute/env.rs`'s `zephyr_env_overrides`, confirmed against +that module's own compiled unit tests (`cargo test -p alp-tan-cli --bin tan +commands::build::execute::env::`) since the oracle binary's `--plan-from` +implies `--plan` and so cannot dispatch a synthetic plan end to end without a +real `alp_orchestrate.py` emission -- see this module's own docstring.""" +import os +from pathlib import Path + +from tan.core.plan_exec import apply_env_append +from tan.core.zephyr_env import zephyr_env_overrides + +SEP = os.pathsep + + +def no_inherited(_key: str) -> str | None: + return None + + +def test_fills_base_and_modules_when_absent(): + got = zephyr_env_overrides( + Path("/ws/zephyr"), Path("/sdk"), slice_env={"ALP_SDK_ROOT": "/sdk"}, + env_append_path={}, inherited=no_inherited, + ) + assert got == [("ZEPHYR_BASE", "/ws/zephyr"), ("EXTRA_ZEPHYR_MODULES", "/sdk")] + + +def test_respects_plan_pinned_keys(): + """The plan already pins both -- nothing is overridden.""" + got = zephyr_env_overrides( + Path("/ws/zephyr"), Path("/sdk"), + slice_env={"ZEPHYR_BASE": "/pinned", "EXTRA_ZEPHYR_MODULES": "/pinned-mod"}, + env_append_path={}, inherited=no_inherited, + ) + assert got == [] + + +def test_skips_extra_modules_when_plan_appends_it(): + """Plan wins / CLI fills gaps: the plan carries EXTRA_ZEPHYR_MODULES in + envAppendPath, so the CLI must NOT hand-derive it -- but ZEPHYR_BASE + (which the plan never carries) is still filled in.""" + got = zephyr_env_overrides( + Path("/ws/zephyr"), Path("/sdk"), slice_env={}, + env_append_path={"EXTRA_ZEPHYR_MODULES": ["/plan/sdk"]}, inherited=no_inherited, + ) + assert got == [("ZEPHYR_BASE", "/ws/zephyr")] + + +def test_empty_when_nothing_resolved(): + assert zephyr_env_overrides(None, None, {}, {}, no_inherited) == [] + + +def test_extends_rather_than_replaces_an_inherited_extra_zephyr_modules(): + """Regression: an earlier shape of this gap-filler returned the bare SDK + root, and the caller's gap-filler merge OVERWRITES the var outright -- so + a developer's own `export EXTRA_ZEPHYR_MODULES=/home/u/my-module` vanished + from the build on any plan that didn't itself pin the key.""" + got = zephyr_env_overrides( + None, Path("/sdk"), slice_env={}, env_append_path={}, + inherited=lambda k: "/home/u/my-module" if k == "EXTRA_ZEPHYR_MODULES" else None, + ) + # EXTRA_ZEPHYR_MODULES is a Zephyr CMake list: joined with ';' on EVERY + # platform (plan_exec.sep_for_key), not os.pathsep. + assert got == [("EXTRA_ZEPHYR_MODULES", "/home/u/my-module;/sdk")] + + +def test_inherited_value_already_containing_the_sdk_root_is_not_duplicated(): + """`apply_env_append`'s own de-dup applies here too -- confirmed by + reusing the exact same helper the plan-driven envAppendPath path uses, + not a re-implementation.""" + got = zephyr_env_overrides( + None, Path("/sdk"), slice_env={}, env_append_path={}, + inherited=lambda k: "/sdk" if k == "EXTRA_ZEPHYR_MODULES" else None, + ) + assert got == [("EXTRA_ZEPHYR_MODULES", "/sdk")] + + +def test_matches_apply_env_append_directly_for_the_fallback_case(): + """The gap-filler's EXTRA_ZEPHYR_MODULES value must be reachable via the + SAME machinery `assemble_slice_env`'s own envAppendPath handling uses -- + not a parallel join implementation that could drift from it.""" + base = [("EXTRA_ZEPHYR_MODULES", "/home/u/my-module")] + apply_env_append(base, {"EXTRA_ZEPHYR_MODULES": ["/sdk"]}) + got = zephyr_env_overrides( + None, Path("/sdk"), slice_env={}, env_append_path={}, + inherited=lambda k: "/home/u/my-module" if k == "EXTRA_ZEPHYR_MODULES" else None, + ) + assert got == [base[0]] From 863bcbae58ae5a7182ec8d99fc24efc41ae0b193 Mon Sep 17 00:00:00 2001 From: Caner Alp <109098482+alpCaner@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:55:20 +0000 Subject: [PATCH 04/10] test(gates): catch issue codes assembled by a prefixing helper (#224) The Rust gate for this landed in 78d8308 (contract.rs's every_prefixed_issue_code_is_registered, with PREFIXING_SITES), but crates/ ships to nobody -- the release assets are PyInstaller freezes of python/ -- so on the shipping surface there was no source-to-registry scan at all, prefixed or otherwise. The shape the Rust gate exists to catch is already live here: bootstrap_cmd builds f"bootstrap.{code}", debug_config_cmd builds f"debug-config.{code}", doctor_cmd does the same. A literal-only scan sees none of them. The new gate walks emit sites for both literal codes and f-string assembly through a prefixing helper, reading the registry through the same source test_frozen_issue_codes.py already uses rather than inventing a second one. It carries a self-test proving it REJECTS a deliberately unregistered code: a gate that cannot fail is not a gate, which this repo learned for real in tan-cli#275 when a scrubbed env var made one skip silently for eleven commits. Where it must skip, it skips loudly and names what was missing. Run against the tree it found 146 unregistered codes, including cli.command-deferred. All are registered `reserved` in contract/ issue-codes.json -- 146 additions, zero removals, zero modifications. cargo test --locked stays green, frozen_issue_codes included. contract/issue-codes.json ships as a release asset; the next release notes should name these entries. --- contract/issue-codes.json | 1314 +++++++++++++++++ .../test_every_issue_code_is_registered.py | 953 ++++++++++++ 2 files changed, 2267 insertions(+) create mode 100644 python/tests/gates/test_every_issue_code_is_registered.py diff --git a/contract/issue-codes.json b/contract/issue-codes.json index 7876bb49..ae3e2cc1 100644 --- a/contract/issue-codes.json +++ b/contract/issue-codes.json @@ -710,6 +710,1320 @@ "emittedBy": "crates/tan-cli/src/commands/support_bundle.rs", "literal": "code: \"support-bundle.server-compatibility\"", "note": "Registered by tan-cli#219, which added the emit-site direction of the gate: every literal issue code under crates/ must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.enclosing-west-workspace", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/bootstrap_cmd.py", + "literal": "\"enclosing-west-workspace\"", + "note": "The `bootstrap.` prefix is applied by `_refusal()`; fires when the intended west topdir sits under an ANCESTOR directory that already has its own `.west` (tan-cli#284's `enclosing_west_workspace_refusal`), distinct from the `workspace-guard` sibling above it (an OCCUPIED relocation target, not an ancestor workspace). Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/bootstrap_cmd.py", + "literal": "\"internal-failure\"", + "note": "Two sites share this spelling: `_refusal(ExitCode.INTERNAL_FAILURE, \"internal-failure\", ...)` for the unreachable `check_prerequisites` fallthrough, and a literal `Issue(\"bootstrap.internal-failure\", \"error\", ...)` in the command's own catch-all `except Exception` backstop. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.python-floor-skew", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/core/bootstrap.py", + "literal": "\"python-floor-skew\"", + "note": "Built by `python_floor_skew_warning()` as a bare `(code, message)` pair, prefixed to `bootstrap.` when `Log.warn(*skew)` drains it; fires whenever the manifest's declared `pythonMinVersion` and the effective (Zephyr-enforced) floor disagree, success or not (tan-cli#300). Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.python-newer-than-verified", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/core/bootstrap.py", + "literal": "\"python-newer-than-verified\"", + "note": "Built by `python_ceiling_warning()`, prefixed via `Log.warn(*ceiling)`; warns (never refuses) when the resolved interpreter is newer than `PYTHON_CEILING_KNOWN_GOOD` (tan-cli#285's other half -- a too-NEW Python is not a guaranteed failure the way too-OLD is). Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.venv-unusable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/core/bootstrap.py", + "literal": "\"venv-unusable\"", + "note": "Built by `posix_venv_unusable()` (Linux only: `python3` runs but its `venv` module cannot create a usable environment because `ensurepip`/`python3-venv` is missing, tan-cli#161/#294); forwarded to the wire through TWO sites -- `bootstrap_cmd.py`'s `Issue(f\"bootstrap.{refusal.code}\", ...)` and `doctor_cmd.py`'s `code=f\"bootstrap.{venv_refusal.code}\"` -- both prefixing the same bare `PrereqFailure.code`. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "bootstrap.workspace-relocation-rolled-back", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/bootstrap_cmd.py", + "literal": "\"workspace-relocation-rolled-back\"", + "note": "tan-cli#284: fires from `rollback_relocation_after()` when a LATER phase (venv/west) fails after the checkout was already relocated, and the rollback itself is reported -- whether the move-back and the pointer restore both succeeded, only the pointer restore failed, or the move-back itself could not complete. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "cli.command-deferred", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/deferred_cmd.py", + "literal": "DEFERRED_ISSUE_CODE = \"cli.command-deferred\"", + "note": "tan-cli#260: shared by all seven verbs this build stubs (`scaffold`, `completion`, `diff`, `pinmux`, `inspect`, `trace`, `support-bundle`), each real in the v0.4.1 oracle and deferred to v0.6.0. Assigned to a module constant and referenced by name at the `Issue(...)` call site, not spelled inline -- see this module's own docstring for why one shared code, not seven, and why `contract/` being open again is what unblocks promoting this note. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.internal-failure\"", + "note": "The port's catch-all `except Exception` backstop -- an uncaught exception reported as a coded envelope instead of a bare traceback. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.missing-tool", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.missing-tool\"", + "note": "Severity is `\"error\"` when the slice actually failed and `\"warning\"` when it was only skipped -- both share this one code, distinguished by `issues[].severity`. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.nothing-built", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.nothing-built\"", + "note": "Every slice was skipped rather than any slice failing outright -- a distinct code from the `build.slice-failed` sibling beside it. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.manifest-unreadable", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.manifest-unreadable\"", + "note": "Best-effort: an unreadable or unparsable system-manifest.yaml is a warning, never fatal -- `clean` must not fail over a manifest it only consults for an optimisation. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.remove-failed", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.remove-failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.sdk-root-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.sdk-root-not-found\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.unsafe-build-root", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.unsafe-build-root\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "clean.unsafe-target", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/clean_cmd.py", + "literal": "\"clean.unsafe-target\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "examples.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/examples_cmd.py", + "literal": "\"examples.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.nothing-flashed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.nothing-flashed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.sdk-root-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.sdk-root-not-found\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.slice-skipped", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.slice-skipped\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.in-process-unavailable", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.in-process-unavailable\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/image_cmd.py", + "literal": "\"image.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.example-missing-board-yaml", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.example-missing-board-yaml\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.emit-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "code=\"kconfig.emit-failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.no-sdk-root", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "code=\"kconfig.no-sdk-root\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.no-workspace", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "code=\"kconfig.no-workspace\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.parse-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "code=\"kconfig.parse-failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.build-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.build-failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.unknown-subcommand", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.unknown-subcommand\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "presets.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/presets_cmd.py", + "literal": "\"presets.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "run.exec-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/run_cmd.py", + "literal": "\"run.exec-failed\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "run.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/run_cmd.py", + "literal": "\"run.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "run.manifest-stale", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/run_cmd.py", + "literal": "\"run.manifest-stale\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "run.native-sim-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/run_cmd.py", + "literal": "\"run.native-sim-unavailable\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.fetch-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "code=\"fetch-failed\"", + "note": "The `sdk.` prefix is applied by `_fail()`. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "code=\"internal-failure\"", + "note": "The `sdk.` prefix is applied by `_fail()`. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.network-required", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "code=\"network-required\"", + "note": "The `sdk.` prefix is applied by `_fail()`; fires when `sdk list` is run without `--online` (this port gates the network call this repo's own oracle reaches unconditionally, so a hermetic/air-gapped run gets a coded refusal instead of a hang). Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.not-ported", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "code=\"not-ported\"", + "note": "The `sdk.` prefix is applied by `_fail()`; `sdk install`/`sdk switch` refuse outright in this build (tan-cli#305). Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.project-pin-unresolved", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "\"sdk.project-pin-unresolved\"", + "note": "tan-cli#263: shared by every caller of `resolve_sdk_tiered` (not just `sdk current`) when `.alp/sdk-path` names a checkout that no longer resolves and the ladder fell through to another tier. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "sdk.unknown-subcommand", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/sdk_cmd.py", + "literal": "code=\"unknown-subcommand\"", + "note": "The `sdk.` prefix is applied by `_fail()`. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.budget-unknown", + "status": "reserved", + "severity": "info", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.budget-unknown\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.internal-failure\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.over-budget", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.over-budget\"", + "note": "Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "validate.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/validate_cmd.py", + "literal": "\"board-yaml-missing\"", + "note": "The `validate.` prefix is applied by the local `fail()` closure. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "validate.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/validate_cmd.py", + "literal": "\"internal-failure\"", + "note": "The `validate.` prefix is applied by the local `fail()` closure; two call sites share it -- an unreadable/non-UTF-8 board.yaml, and `validate_board_text` raising unexpectedly. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "validate.schema-violation", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/validate_cmd.py", + "literal": "\"schema-violation\"", + "note": "The `validate.` prefix is applied by the local `fail()` closure (the `BoardShapeError` path) and, separately, by `Issue(f\"validate.{result.outcome}\", ...)` -- `result.outcome` is only ever `OUTCOME_SCHEMA_VIOLATION` (\"schema-violation\") on that path, since a clean result carries no messages to iterate. Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "validate.spawn-not-implemented", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/validate_cmd.py", + "literal": "\"spawn-not-implemented\"", + "note": "The `validate.` prefix is applied by the local `fail()` closure; the full (spawn) validator is not ported yet -- run with `--offline` (tan-cli#262). Registered by tan-cli#224, which added the Python-side emit-site gate (python/tests/gates/test_every_issue_code_is_registered.py): every issue code python/tan emits -- literal or assembled by a prefixing helper -- must appear in this registry at some status. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.artefact-write-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/materialise.py", + "literal": "\"build.artefact-write-failed\"", + "note": "Constructed as the whole literal at a `MaterialiseError` call site in `materialise.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.conflicting-flags", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.conflicting-flags\"", + "note": "Constructed as the whole literal at a `_refuse` call site in `build_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.materialise-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.materialise-failed\"", + "note": "Constructed as the whole literal at a `BuildError` call site in `build_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.path-escape", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/materialise.py", + "literal": "\"build.path-escape\"", + "note": "Constructed as the whole literal at a `MaterialiseError` call site in `materialise.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.plan-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/core/build_plan.py", + "literal": "\"build.plan-invalid\"", + "note": "Constructed as the whole literal at a `PlanParseError` call site in `build_plan.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.plan-token-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/token_substitution.py", + "literal": "\"build.plan-token-unresolved\"", + "note": "Constructed as the whole literal at a `TokenSubstitutionError` call site in `token_substitution.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.plan-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build_cmd.py", + "literal": "\"build.plan-unavailable\"", + "note": "Constructed as the whole literal at a `BuildError` call site in `build_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.plan-unsupported-schema", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/core/build_plan.py", + "literal": "\"build.plan-unsupported-schema\"", + "note": "Constructed as the whole literal at a `PlanParseError` call site in `build_plan.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.project-root-mismatch", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/token_substitution.py", + "literal": "\"build.project-root-mismatch\"", + "note": "Constructed as the whole literal at a `TokenSubstitutionError` call site in `token_substitution.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.sdk-commit-mismatch", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/token_substitution.py", + "literal": "\"build.sdk-commit-mismatch\"", + "note": "Constructed as the whole literal at a `TokenSubstitutionError` call site in `token_substitution.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "build.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/build/token_substitution.py", + "literal": "\"build.sdk-root-unresolved\"", + "note": "Constructed as the whole literal at a `TokenSubstitutionError` call site in `token_substitution.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.ambiguous-selector", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.ambiguous-selector\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.internal-failure\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.positional-template-conflict", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.positional-template-conflict\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.target-unknown", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.target-unknown\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.template-unknown", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.template-unknown\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "explain.template-unreadable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/explain_cmd.py", + "literal": "\"explain.template-unreadable\"", + "note": "Constructed as the whole literal at a `ExplainError` call site in `explain_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.manifest-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.manifest-invalid\"", + "note": "Constructed as the whole literal at a `_error` call site in `flash_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "flash.manifest-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/flash_cmd.py", + "literal": "\"flash.manifest-not-found\"", + "note": "Constructed as the whole literal at a `_error` call site in `flash_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.board-sku-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.board-sku-unresolved\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.board-yaml-missing\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.internal-failure\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.invalid-executor", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.invalid-executor\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.invalid-target", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.invalid-target\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.output-unwritable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.output-unwritable\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.python-too-old", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.python-too-old\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.sdk-root-unresolved\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "generate.would-overwrite", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.would-overwrite\"", + "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.manifest-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/image_cmd.py", + "literal": "\"image.manifest-invalid\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `image_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.manifest-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/image_cmd.py", + "literal": "\"image.manifest-unavailable\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `image_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.board-yaml-unreadable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.board-yaml-unreadable\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.board-yaml-unsupported", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.board-yaml-unsupported\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.example-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.example-not-found\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.example-unreadable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.example-unreadable\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.internal-failure\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-cores", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-cores\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-example", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-example\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-name", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-name\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-som", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-som\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-template", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-template\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.sdk-root-unresolved\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.template-unreadable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.template-unreadable\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.board-yaml-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "\"kconfig.board-yaml-invalid\"", + "note": "Constructed as the whole literal at a `_CoreResolutionError` call site in `kconfig_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "\"kconfig.board-yaml-missing\"", + "note": "Constructed as the whole literal at a `_CoreResolutionError` call site in `kconfig_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.core-ambiguous", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "\"kconfig.core-ambiguous\"", + "note": "Constructed as the whole literal at a `_CoreResolutionError` call site in `kconfig_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.board-yaml-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.board-yaml-invalid\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.board-yaml-missing\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.build-timeout", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.build-timeout\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.python-too-old", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.python-too-old\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.sdk-root-unresolved\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.launch-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.launch-failed\"", + "note": "Constructed as the whole literal at a `MonitorError` call site in `monitor_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.no-port", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.no-port\"", + "note": "Constructed as the whole literal at a `MonitorError` call site in `monitor_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.pyserial-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.pyserial-missing\"", + "note": "Constructed as the whole literal at a `MonitorError` call site in `monitor_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.argv-rejected", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.argv-rejected\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.binary-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.binary-missing\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.cpu-halted", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.cpu-halted\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.descriptor", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.descriptor\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.descriptor-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.descriptor-missing\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.elf-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.elf-missing\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.exited-nonzero", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.exited-nonzero\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.expect-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.expect-not-found\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.image-bundle-unused", + "status": "reserved", + "severity": "info", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.image-bundle-unused\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.manifest-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.manifest-invalid\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.manifest-schema", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.manifest-schema\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.manifest-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.manifest-unavailable\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.run-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.run-failed\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sdk-root-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.sdk-root-not-found\"", + "note": "Constructed as the whole literal at a `fail` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sku-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.sku-unresolved\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.slice", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.slice\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.manifest-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.manifest-invalid\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `size_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.manifest-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.manifest-unavailable\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `size_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.boardYaml", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"boardYaml\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.bootstrapManifest", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"bootstrapManifest\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.homePath", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"homePath\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.hostPrerequisites", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"hostPrerequisites\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.hostPython", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"hostPython\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.jlink", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"jlink\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.longPaths", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"longPaths\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.pythonFloor", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"pythonFloor\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.sdk", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"sdk\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.sdkProvenance", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"sdkProvenance\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.setools", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"setools\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.sevenZip", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"sevenZip\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.venvProvenance", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"venvProvenance\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.west", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"west\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.westResolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"westResolved\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.workspace", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"workspace\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrSdk", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrSdk\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrSdkAvailableForHost", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrSdkAvailableForHost\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrVersion", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrVersion\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrWorkspace", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrWorkspace\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "migrate.failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/west_forward_cmd.py", + "literal": ".failed", + "note": "The mirrored `f\"{subcommand}.failed\"` shape (family substituted, suffix fixed) at `python/tan/commands/west_forward_cmd.py`'s `_run_forward` -- the reverse of `bootstrap.`-style prefixing, and a second live call site of the exact defect tan-cli#224 reports (a code assembled by interpolation no literal scan can see) found while remediating that issue's own review. `subcommand` is closed to the three literal strings `tan migrate`'s own Typer command passes (`migrate`/`lock`/`quality`, `cli.py`). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "lock.failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/west_forward_cmd.py", + "literal": ".failed", + "note": "The mirrored `f\"{subcommand}.failed\"` shape (family substituted, suffix fixed) at `python/tan/commands/west_forward_cmd.py`'s `_run_forward` -- the reverse of `bootstrap.`-style prefixing, and a second live call site of the exact defect tan-cli#224 reports (a code assembled by interpolation no literal scan can see) found while remediating that issue's own review. `subcommand` is closed to the three literal strings `tan lock`'s own Typer command passes (`migrate`/`lock`/`quality`, `cli.py`). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "quality.failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/west_forward_cmd.py", + "literal": ".failed", + "note": "The mirrored `f\"{subcommand}.failed\"` shape (family substituted, suffix fixed) at `python/tan/commands/west_forward_cmd.py`'s `_run_forward` -- the reverse of `bootstrap.`-style prefixing, and a second live call site of the exact defect tan-cli#224 reports (a code assembled by interpolation no literal scan can see) found while remediating that issue's own review. `subcommand` is closed to the three literal strings `tan quality`'s own Typer command passes (`migrate`/`lock`/`quality`, `cli.py`). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." } ] } diff --git a/python/tests/gates/test_every_issue_code_is_registered.py b/python/tests/gates/test_every_issue_code_is_registered.py new file mode 100644 index 00000000..9ce17aff --- /dev/null +++ b/python/tests/gates/test_every_issue_code_is_registered.py @@ -0,0 +1,953 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#224: the Python emit-site gate the Rust one cannot stand in for. + +`crates/tan-cli/tests/contract.rs` carries a PAIR of tests -- +`every_emitted_issue_code_is_registered` (tan-cli#219: walks every literal +`code: "family.name"` in `crates/` and asserts it is in +`contract/issue-codes.json` at some status) and +`every_prefixed_issue_code_is_registered` (tan-cli#224 itself: a DECLARED +list of `PREFIXING_SITES`, because a code assembled as +`format!("bootstrap.{code}")` from a bare suffix never appears as one whole +literal and the first test structurally cannot see it). Both landed in +commit 78d8308. + +`crates/` ships to NOBODY -- the release assets are PyInstaller freezes of +`python/tan` (tan-cli#271) -- so on the surface that actually reaches a +customer, NEITHER direction of this gate existed until this file. The +prefixing shape is not hypothetical on the Python side either: +`bootstrap_cmd.py`, `debug_config_cmd.py`, `doctor_cmd.py`, `sdk_cmd.py` and +`validate_cmd.py` all build a code the same way, and `deferred_cmd.py`'s +`cli.command-deferred` sat completely unregistered (assigned to a module +constant, never a whole literal at its `Issue(...)` call site) until the +audit this file's first run performed. + +WHAT THIS COVERS -- WIDER than the Rust pair's own two shapes, by design, and +that widening is itself the product of a remediation: an earlier version of +this file claimed parity with the Rust pair's two shapes while actually +implementing a narrower one, which left ~40% of tan's emitted codes ungated +(caught in review, not by the gate -- the exact fail-open requirement 4 below +exists to prevent, applied to this file about itself): + + 1. LITERAL sites -- `Issue("family.code", ...)`, `code="family.code"` + anywhere, and `Issue(NAME, ...)` where `NAME` is a module-level constant + assigned exactly that literal (`cli.command-deferred`'s actual shape). + 2. FULL-CODE-CARRYING CALL sites -- [`_FULL_CODE_CALLABLES`]: the port's + DOMINANT emit idiom is not `Issue("family.code", ...)` directly but a + per-command error TYPE (`BuildError`, `InitError`, `GenerateError`, ...) + or a small local wrapper (`_issue`, `fail_sdk`, `_refuse`, `_error`, ...) + constructed with the WHOLE literal code, later re-emitted through + `Issue(err.code, ...)` several frames away. Shape (1) structurally + cannot see the literal, because it never appears as `Issue(...)`'s own + argument -- it appears at the CONSTRUCTOR call. Declared per `(file, + callable name)` -> the positional index of the code argument, every call + site scanned, exactly like shape (3) below scans a prefixing helper's + call sites; a non-literal argument there is either a declared forward + ([`_KNOWN_CODE_FORWARDS`], e.g. `except BuildError as err: ... + Issue(err.code, ...)`, whose literal is captured at `err`'s OWN + construction site) or reported UNRESOLVED, never silently dropped. + 3. PREFIXED / FAMILY sites -- an f-string whose ENTIRE value is one literal + segment ending or starting with `.` plus exactly one substitution, in + EITHER order: `f"bootstrap.{code}"` (fixed prefix, substituted suffix) + or `f"{subcommand}.failed"` (substituted family, fixed suffix -- + `west_forward_cmd.py`'s mirrored shape). Auto-DISCOVERED across the + whole `tan/` tree (unlike the Rust list, which is hand-declared per + file) and then RESOLVED by scanning every call site of the one helper + function/constructor the f-string's substitution comes from, mirroring + `PREFIXING_SITES`'s "declared opener, scanned call sites, pinned count" + shape one level more automatically. + +Auto-discovery is the deliberate improvement over the Rust design for shape +(3): the Rust gate can only see a prefixing helper someone already added a +row for, so a FOURTH helper appearing elsewhere in `crates/` would escape +both of its own tests silently. Here, [`_prefix_templates`] finds every +"one fixed literal segment + one substitution" f-string in the tree by its +AST SHAPE, not by a hand-maintained file list, and +[`test_every_prefix_template_is_classified`] fails the moment one appears +that nothing below has classified -- so a fifth helper cannot hide the way a +fourth Rust one could. Shape (2) is declared rather than auto-discovered +(a bare `SomeClass("family.code", ...)` call has no AST feature that +distinguishes "this constructs a code-carrying error" from "this constructs +an unrelated value" the way a `f"prefix.{x}"` shape does), so it carries the +same non-vacuity discipline shape (3) does at the registration/test level +instead: [`test_every_emitted_issue_code_is_registered`]'s own count +(`len(literal) > 30`) would drop sharply if a `_FULL_CODE_CALLABLES` entry +silently stopped matching, the same tripwire `expected_calls` gives shape (3). + +Classifying what auto-discovery FINDS still takes a human, in one of three +declared buckets, the same non-heuristic discipline +`crates/tan-cli/tests/contract.rs`'s own `PREFIXING_SITES` and +`DECLARED_FORWARDERS` comments insist on ("a scan that guessed which +functions prefix would either miss a new one silently or invent codes from +unrelated calls"): + + * `_RESOLVABLE_HELPERS`, keyed by `(file, lineno)` of the f-string itself -- + the substituted name is a plain parameter of the enclosing + function/method (`kind="prefix"`, the fixed literal is a PREFIX) or, + mirrored, a parameter of the function the f-string's SUBSTITUTED family + comes from while the SUFFIX is fixed (`kind="family"`, + `west_forward_cmd.py`'s `f"{subcommand}.failed"`) -- either way, every + call site of that one function is scanned, and a literal argument there + IS the missing half. Also covers a constructor whose call sites are + scanned the same way even though the substitution is not literally the + enclosing function's own parameter: `doctor_cmd.py`'s + `f"doctor.{check.name}"` resolves by scanning every `Check(...)` + construction's `name` (48 call sites, all literal -- MEASURED, not + assumed, while closing tan-cli#224's own review). A call passing + something else (a `Name`, an `Attribute`, a `Starred` unpack) is + unresolved unless it also appears in `_FORWARDER_SUFFIXES`. + * `_FORWARDER_SUFFIXES`, keyed by `(file, exact substituted expression)` -- + the substituted expression is not a plain parameter (`refusal.code`, + `venv_refusal.code`, `result.outcome`, or a `*tuple` unpack) but its + value space was read from the real source and is small and closed (a + dataclass field fed by a handful of constructors, or an outcome derived + from two module constants). + * `_ACKNOWLEDGED_CEILINGS`, keyed by `(file, lineno)` -- stated rather than + silently skipped, the same honesty the Rust gate's own "KNOWN CEILING" + paragraph practises. Held EMPTY today: `doctor_cmd.py`'s + `check.code or f"doctor.{check.name}"` ceiling this bucket used to carry + was resolved into `_RESOLVABLE_HELPERS` above once its actual cost was + measured (48 call sites, ALL literal, zero non-literal -- "a materially + bigger audit" was the ceiling's original claim, and it did not survive + contact with the real count). The bucket stays declared rather than + deleted so a FUTURE genuinely-out-of-scope template has somewhere + honest to go, per the same Rust "KNOWN CEILING" precedent -- an empty + table is not evidence no ceiling will ever be needed again. + +Shape (2) sites (`_FULL_CODE_CALLABLES`, see above) get the same declared, no +silent drop treatment through [`_KNOWN_CODE_FORWARDS`]: a code-position +argument (an `Issue(...)` first arg, any `code=` keyword, or a +`_FULL_CODE_CALLABLES` argument) that is neither a literal nor a resolved +module constant is either a declared forward -- its literal captured at the +ORIGIN this list points at, mirroring `crates/tan-cli/tests/contract.rs`'s +own `DECLARED_FORWARDERS` ("this list only says there is no literal HERE to +read, which is a fact about the call, not a licence to skip the code") -- or +reported UNRESOLVED by file:line, never silently dropped. + +Every one of these buckets is a place a REAL escape can still happen if +mis-declared -- which is exactly why +[`test_gate_rejects_a_deliberately_unregistered_code`] exists: tan-cli#275 is +the standing lesson that an assertion nobody has ever seen fail is not proven +to fire. Confirmed by hand while writing this file: with the fabricated code +below removed from the injected set the assertion goes red; restored, green +-- see that test's own body for the same check run programmatically. +""" + +from __future__ import annotations + +import ast +import json +import pathlib + +#: `contract/` lives at the repo root, one level above `python/` -- the same +#: resolution `test_frozen_issue_codes.py` uses, reused rather than +#: reinvented so there is exactly one place that path is computed. +REGISTRY = pathlib.Path(__file__).resolve().parents[3] / "contract" / "issue-codes.json" +TAN = pathlib.Path(__file__).resolve().parents[2] / "tan" + + +def _registered_codes() -> set[str]: + data = json.loads(REGISTRY.read_text(encoding="utf-8")) + codes = {e["code"] for e in data["issueCodes"]} + # Non-vacuity: an empty (or unreadable-as-expected) registry would make + # every assertion below pass by finding nothing to fail against -- + # exactly the tan-cli#275 shape this whole file exists to avoid. + assert codes, f"{REGISTRY} has no issue codes -- this gate would be vacuous" + return codes + + +def _is_code_literal(s: str) -> bool: + """Shaped like a whole `family.name` issue code: at least one dot, + otherwise lowercase/digits/dash/dot only. Deliberately narrow, the same + reason `crates/tan-cli/tests/contract.rs::emitted_code_literals` is + narrow -- so an unrelated `code="UTF-8"`-shaped kwarg or a prose string + can never be mistaken for a real code.""" + return bool(s) and "." in s and all(c.islower() or c.isdigit() or c in "-." for c in s) + + +def _is_code_suffix(s: str) -> bool: + """Shaped like the bare SUFFIX a prefixing helper takes: no dot at all + (a dot there would mean the caller already passed a whole code, which is + a literal-emit site, not a prefixed one). Two shapes accepted: the + kebab-case convention every HAND-WRITTEN suffix in this tree uses + (`board-yaml-missing`), or a bare camelCase identifier for the one + MECHANICALLY-resolved exception -- `doctor_cmd.py`'s `Check(...)` `name`s + mirror the Rust oracle's own `doctor.` convention verbatim + (`checks_to_issues`'s own docstring says so), so `boardYaml`/`zephyrSdk`/ + ... are real suffixes this scan must accept, not reject as malformed.""" + if not s or "." in s: + return False + if all(c.islower() or c.isdigit() or c == "-" for c in s): + return True + return s[0].islower() and all(c.isalnum() for c in s) + + +def _is_family_prefix(s: str) -> bool: + """Shaped like `"bootstrap."` -- lowercase/dash, exactly one trailing + dot and no other. Guards [`_prefix_templates`] against an unrelated + f-string (a path, a URL) that happens to end a literal segment in `.`.""" + return s.endswith(".") and s.count(".") == 1 and len(s) > 1 and all(c.islower() or c == "-" for c in s[:-1]) + + +def _is_family_suffix(s: str) -> bool: + """Shaped like `".failed"` -- the MIRROR of [`_is_family_prefix`]: one + leading dot, then lowercase/dash, no other dot. Guards the "substituted + FAMILY, fixed SUFFIX" template shape (`f"{subcommand}.failed"`, + `west_forward_cmd.py`) against an unrelated f-string ending a literal + segment in `.something` that is not a code suffix.""" + return s.startswith(".") and s.count(".") == 1 and len(s) > 1 and all(c.islower() or c == "-" for c in s[1:]) + + +def _parse(path: pathlib.Path) -> ast.Module: + return ast.parse(path.read_text(encoding="utf-8", errors="replace"), filename=str(path)) + + +def _rel(path: pathlib.Path) -> str: + """`tan/commands/foo.py`, the same spelling used throughout this file's + declared tables -- so a table entry can be found by grepping this file + for the exact string that would appear in a failure message. + + Falls back to `path` unchanged for a path outside `tan/`: a pytest + `tmp_path` self-test scratch file (`test_prefix_template_scan_finds_a_fresh_synthetic_site`) + is scanned by `_literal_codes_in_file`, which now calls `_rel` to key its + `_FULL_CODE_CALLABLES`/`_KNOWN_CODE_FORWARDS` lookups -- no declared table + entry can ever match a path outside `tan/`, so the exact spelling does not + matter there, but crashing on `relative_to` does.""" + try: + return str(path.relative_to(TAN.parent)).replace("\\", "/") + except ValueError: + return str(path).replace("\\", "/") + + +def _module_string_constants(tree: ast.Module) -> dict[str, str]: + """Module-level `NAME = "literal.with.a.dot"` assignments -- the + `cli.command-deferred` shape (`DEFERRED_ISSUE_CODE` in + `deferred_cmd.py`), where the whole code is named once and referenced by + identifier at the `Issue(...)` call site rather than spelled inline. + Deliberately shallow: only a direct top-level `Assign` to a `Name` + counts, so a value reassigned or computed elsewhere is correctly left + unresolved rather than guessed at.""" + consts: dict[str, str] = {} + for node in tree.body: + if ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + and _is_code_literal(node.value.value) + ): + consts[node.targets[0].id] = node.value.value + return consts + + +#: `(file, callable name)` -> the positional index of the argument that +#: carries an ALREADY-WHOLE issue code, for constructors/local helpers whose +#: declared purpose is exactly that (not a bare suffix needing a prefix -- +#: `_RESOLVABLE_HELPERS` below covers that shape). See the module docstring's +#: shape (2): the port's dominant emit idiom is a per-command error TYPE +#: (`BuildError`, `InitError`, ...) or a small local wrapper (`_issue`, +#: `fail_sdk`, `_refuse`, `_error`, `_error_outcome`, `_Notice`) constructed +#: with the whole code, later re-emitted through `Issue(err.code, ...)` -- +#: several call frames from the eventual `Issue(...)` shape (1) alone can see. +#: Every entry here was read from source (a `grep` for the class/function +#: name, then its `__init__`/signature, then every call site), the same +#: discipline `_RESOLVABLE_HELPERS`/`_FORWARDER_SUFFIXES` already apply. +_FULL_CODE_CALLABLES: dict[tuple[str, str], int] = { + ("tan/core/build_plan.py", "PlanParseError"): 0, + ("tan/commands/monitor_cmd.py", "MonitorError"): 0, + ("tan/commands/explain_cmd.py", "ExplainError"): 0, + ("tan/commands/generate_cmd.py", "GenerateError"): 0, + ("tan/commands/build/token_substitution.py", "TokenSubstitutionError"): 0, + ("tan/commands/build_cmd.py", "BuildError"): 0, + ("tan/commands/build_cmd.py", "_refuse"): 0, + ("tan/commands/build/materialise.py", "MaterialiseError"): 0, + ("tan/commands/model_cmd.py", "ModelError"): 0, + ("tan/commands/kconfig_cmd.py", "_CoreResolutionError"): 0, + ("tan/commands/init_cmd.py", "InitError"): 0, + ("tan/commands/renode_cmd.py", "_issue"): 0, + ("tan/commands/renode_cmd.py", "fail"): 0, + ("tan/commands/renode_cmd.py", "fail_sdk"): 0, + ("tan/commands/flash_cmd.py", "_error"): 1, + ("tan/commands/image_cmd.py", "_error_outcome"): 3, + ("tan/commands/image_cmd.py", "_Notice"): 0, + ("tan/commands/size_cmd.py", "_error_outcome"): 2, +} + +#: `(file, exact unparsed expression)` -> declared as a KNOWN forward, never +#: a silent skip -- mirrors `crates/tan-cli/tests/contract.rs`'s own +#: `DECLARED_FORWARDERS` ("this list only says there is no literal HERE to +#: read, which is a fact about the call, not a licence to skip the code"). +#: Applies at every code-position argument this file inspects: `Issue(...)`'s +#: first arg, any `code=` keyword, and every `_FULL_CODE_CALLABLES` argument. +#: Every entry's literal IS captured elsewhere in this same scan: an +#: `except Error as err:` block re-emitting `err.code` (`Error` is +#: itself in `_FULL_CODE_CALLABLES`, so its OWN construction sites carry the +#: literal), or a module constant imported from another file (`deferred_cmd +#: .py`'s `DEFERRED_ISSUE_CODE`, resolved by `_module_string_constants` only +#: at ITS OWN definition site -- deliberately shallow, per that function's own +#: docstring -- so the cross-module import here needs its own declared entry). +_KNOWN_CODE_FORWARDS: frozenset[tuple[str, str]] = frozenset( + { + ("tan/commands/build_cmd.py", "err.code"), # BuildError <- PlanParseError/TokenSubstitutionError + ("tan/commands/build_cmd.py", "DEFERRED_ISSUE_CODE"), # imported from deferred_cmd.py + ("tan/commands/build_cmd.py", "code"), # `Issue(code, ...)` inside `_refuse`'s OWN body, + # forwarding ITS OWN `code` parameter -- `_refuse` is itself in + # `_FULL_CODE_CALLABLES`, so its call sites carry the literal. + ("tan/commands/monitor_cmd.py", "err.code"), # <- MonitorError + ("tan/commands/init_cmd.py", "err.code"), # <- InitError + ("tan/commands/model_cmd.py", "err.code"), # <- ModelError + ("tan/commands/explain_cmd.py", "err.code"), # <- ExplainError + ("tan/commands/run_cmd.py", "err.code"), # <- BuildError (run retags build's own refusal) + ("tan/commands/generate_cmd.py", "err.code"), # <- GenerateError + ("tan/commands/kconfig_cmd.py", "err.code"), # <- _CoreResolutionError (via `code=err.code`) + ("tan/commands/kconfig_cmd.py", "code"), # `Issue(code, ...)` inside `_fail`'s OWN body -- + # `_fail`'s literal `code=` call sites are already caught by the plain + # `code=` keyword scan above; this is only its internal forward. + ("tan/commands/flash_cmd.py", "code"), # `Issue(code, ...)` inside `_error`'s OWN body -- + # `_error` is itself in `_FULL_CODE_CALLABLES`. + ("tan/commands/image_cmd.py", "n.code"), # <- _Notice, one per bundle-assembly gap + ("tan/commands/image_cmd.py", "code"), # `Issue(code, ...)` inside `_error_outcome`'s OWN + # body -- `_error_outcome` is itself in `_FULL_CODE_CALLABLES`. + ("tan/commands/size_cmd.py", "code"), # same shape, `size_cmd.py`'s own `_error_outcome`. + ("tan/commands/renode_cmd.py", "code"), # `_issue(code, ...)` inside `fail`/`fail_sdk`'s OWN + # bodies, forwarding THEIR OWN `code` parameter -- `fail`/`fail_sdk` + # are themselves in `_FULL_CODE_CALLABLES`, so their call sites carry it. + } +) + + +def _resolve_code_value( + rel: str, value: ast.expr | None, consts: dict[str, str] +) -> tuple[str, str | None]: + """Classify one code-position argument. Returns `(status, payload)`: + + * `("literal", code)` -- `value` is a resolvable code literal (a + `Constant` shaped like a whole `family.name` code, or a known + module-string-constant `Name`). + * `("ignored", None)` -- `value` is owned by a DIFFERENT, already-asserted + mechanism, so reporting it here would duplicate that mechanism's own + check rather than add coverage: a `Constant` string that is NOT + code-shaped (no dot -- a bare SUFFIX literal, e.g. `_fail(code="not- + ported", ...)`, which `_RESOLVABLE_HELPERS`/`_resolve_helper` scans + these exact call sites for separately), or an `ast.JoinedStr`/ + `ast.BoolOp` (an f-string or `x or f"..."` -- the family/prefix- + template shape `_prefix_templates`/`_classify_and_resolve` owns, with + its own unresolved/unclassified assertions). + * `("forward", None)` -- `(rel, unparsed value)` is declared in + `_KNOWN_CODE_FORWARDS` -- deliberately skipped, the code is captured at + its own origin. + * `("unresolved", unparsed value)` -- none of the above: a real escape, + reported by file:line, never silently dropped. + """ + if isinstance(value, ast.Constant) and isinstance(value.value, str): + if _is_code_literal(value.value): + return "literal", value.value + return "ignored", None + if isinstance(value, ast.Name) and value.id in consts: + return "literal", consts[value.id] + if isinstance(value, (ast.JoinedStr, ast.BoolOp)): + return "ignored", None + expr = ast.unparse(value) if value is not None else "" + if (rel, expr) in _KNOWN_CODE_FORWARDS: + return "forward", None + return "unresolved", expr + + +def _literal_codes_in_file(path: pathlib.Path) -> tuple[set[str], list[str]]: + """Every LITERAL whole-code emit site in one file, plus every + code-position argument that could NOT be resolved (never silently + dropped -- tan-cli#224's own review finding). Three shapes: + `Issue("family.code", ...)` (first positional arg, resolving a module + constant when the arg is a bare `Name`), `code="family.code"` (a keyword + named `code`, wherever it appears -- deliberately not scoped to any one + callee, the same breadth `contract.rs`'s `code: "..."` text scan has), + and a call to any `_FULL_CODE_CALLABLES` entry declared for THIS file.""" + rel = _rel(path) + tree = _parse(path) + consts = _module_string_constants(tree) + callables_here = {name: idx for (f, name), idx in _FULL_CODE_CALLABLES.items() if f == rel} + found: set[str] = set() + unresolved: list[str] = [] + + def _record(lineno: int, site: str, value: ast.expr | None) -> None: + status, payload = _resolve_code_value(rel, value, consts) + if status == "literal": + assert payload is not None + found.add(payload) + elif status == "unresolved": + unresolved.append(f"{rel}:{lineno} -- {site} argument is not a resolvable code literal ({payload})") + # "forward" / "ignored": declared safe or owned elsewhere -- nothing to record. + + for node in ast.walk(tree): + if isinstance(node, ast.keyword) and node.arg == "code": + _record(node.lineno, "a `code=` keyword", node.value) + continue + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + if node.func.id == "Issue" and node.args: + _record(node.lineno, "`Issue(...)`'s first", node.args[0]) + continue + if node.func.id in callables_here: + idx = callables_here[node.func.id] + arg = node.args[idx] if len(node.args) > idx else None + _record(node.lineno, f"`{node.func.id}(...)`'s (declared in _FULL_CODE_CALLABLES)", arg) + continue + return found, unresolved + + +def _prefix_templates(path: pathlib.Path) -> list[tuple[int, str, str, str]]: + """Every f-string used AT A CODE POSITION in `path` -- `Issue(...)`'s + first argument, or a `code=` keyword's value, the SAME two positions + [`_literal_codes_in_file`] inspects -- shaped EXACTLY `[one fixed literal + segment ending/starting with `.`, ONE substitution]`, in either order -- + e.g. `f"bootstrap.{code}"` (`kind="prefix"`) or `f"{subcommand}.failed"` + (`kind="family"`). Returns `(lineno, literal segment, unparsed + substituted expression, kind)`. + + Scoping to code positions (rather than "any f-string in the file") is + load-bearing, not cosmetic: this tree has MANY unrelated f-strings + sharing the bare dot-suffix SHAPE -- `f"{sku}.yaml"`, `f"{tool}.exe"`, + `f"{field}.path"` -- that `_is_family_suffix` alone cannot distinguish + from a real code-assembling template (unlike `_is_family_prefix`'s + multi-char prefixes, which happen not to collide with anything else in + this tree today). `_is_family_prefix`/`_is_family_suffix` narrow the + SHAPE; scoping to code positions narrows WHERE that shape is even + looked for -- caught by measurement while closing tan-cli#224's own + review (28 false positives from an unscoped scan, none of them a real + issue-code template). + + Walks the WHOLE subtree of each code-position expression (not just its + top level), so a JoinedStr nested one level in -- `doctor_cmd.py`'s + `check.code or f"doctor.{check.name}"`, a `BoolOp` -- is still found. + """ + tree = _parse(path) + out: list[tuple[int, str, str, str]] = [] + + def _scan(value: ast.expr | None) -> None: + if value is None: + return + for node in ast.walk(value): + if not isinstance(node, ast.JoinedStr) or len(node.values) != 2: + continue + head, tail = node.values + if ( + isinstance(head, ast.FormattedValue) + and isinstance(tail, ast.Constant) + and isinstance(tail.value, str) + and _is_family_suffix(tail.value) + ): + out.append((node.lineno, tail.value, ast.unparse(head.value), "family")) + continue + if ( + isinstance(head, ast.Constant) + and isinstance(head.value, str) + and _is_family_prefix(head.value) + and isinstance(tail, ast.FormattedValue) + ): + out.append((node.lineno, head.value, ast.unparse(tail.value), "prefix")) + + for node in ast.walk(tree): + if isinstance(node, ast.keyword) and node.arg == "code": + _scan(node.value) + continue + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "Issue" and node.args: + _scan(node.args[0]) + return out + + +# --------------------------------------------------------------------------- +# The declared classification of every prefix template this tree contains +# today (tan-cli#224). See the module docstring for what each bucket means +# and why a fourth is deliberately not offered. +# --------------------------------------------------------------------------- + +#: The exact number of `_prefix_templates` matches across the whole `tan/` +#: tree, TODAY. Pinned exactly, the same reason +#: `crates/tan-cli/tests/contract.rs`'s `PREFIXED_CODE_COUNT` and per-file +#: `expected_sites` are pinned rather than floored: a template silently +#: disappearing (a rename that stops covering an emit) is exactly as real a +#: defect as a new one silently appearing uncovered, and only an EXACT count +#: notices the first case. +EXPECTED_TEMPLATE_COUNT = 11 + +#: `(file, lineno-of-the-f-string)` -> how to recover the missing half, for +#: every template resolvable by scanning one declared callable's call sites. +#: Keyed on the f-string's own line (not `(file, prefix, expr)`) because +#: `bootstrap_cmd.py` has TWO distinct such templates that both happen to +#: substitute a parameter named `code` -- (file, prefix, expr) alone cannot +#: tell them apart. `kind` (default `"prefix"`) picks which half is fixed: +#: `"prefix"` -- `prefix` is the fixed literal, `expr`'s call-site argument is +#: the SUFFIX (`f"bootstrap.{code}"`, `bootstrap.` + scanned code); `"family"` +#: -- `suffix` is the fixed literal, `expr`'s call-site argument is the FAMILY +#: (`f"{subcommand}.failed"`, scanned subcommand + `.failed`). Either way the +#: named callable's call sites are scanned the same way (`name`/`attr`, +#: `arg_index`/`arg_keyword`) -- a constructor whose call sites carry the +#: missing half works exactly like a helper whose OWN parameter does +#: (`doctor_cmd.py`'s `Check(name=...)` below is a constructor, not the +#: f-string's enclosing function; `_resolve_helper` does not care which). +_RESOLVABLE_HELPERS: dict[tuple[str, int], dict] = { + ("tan/commands/bootstrap_cmd.py", 271): dict( + # `Log.warn(self, code, message)`, drained by `take_issues` into + # this exact f-string -- every call site is `.warn(...)`. + prefix="bootstrap.", + expr="code", + attr="warn", + arg_index=0, + expected_calls=16, + ), + ("tan/commands/bootstrap_cmd.py", 1522): dict( + prefix="bootstrap.", + expr="code", + name="_refusal", + arg_index=1, + expected_calls=8, + ), + ("tan/commands/debug_config_cmd.py", 749): dict( + prefix="debug-config.", + expr="code", + name="_failure", + arg_keyword="code", + expected_calls=2, + ), + ("tan/commands/sdk_cmd.py", 779): dict( + prefix="sdk.", + expr="code", + name="_fail", + arg_keyword="code", + expected_calls=5, + ), + ("tan/commands/validate_cmd.py", 485): dict( + prefix="validate.", + expr="code", + name="fail", + arg_index=0, + expected_calls=5, + ), + ("tan/commands/doctor_cmd.py", 1706): dict( + # `check.code or f"doctor.{check.name}"` in `checks_to_issues()` -- + # the ceiling this bucket used to acknowledge instead of resolving + # (tan-cli#224 review): MEASURED at 48 `Check(...)` constructions, + # every one passing its `name` positionally and literally, zero + # non-literal, 20 distinct camelCase names (`_is_code_suffix` admits + # camelCase for exactly this reason -- see its own docstring). + prefix="doctor.", + expr="check.name", + name="Check", + arg_index=0, + expected_calls=48, + ), + ("tan/commands/west_forward_cmd.py", 124): dict( + # `Issue(f"{subcommand}.failed", ...)` -- the MIRRORED shape + # (tan-cli#224 review): `subcommand` is `_run_forward`'s own + # parameter, closed to the three literal strings its three Typer + # callers (`migrate`/`lock`/`quality`) pass. + kind="family", + suffix=".failed", + expr="subcommand", + name="_run_forward", + arg_index=0, + expected_calls=3, + ), + ("tan/commands/west_forward_cmd.py", 133): dict( + kind="family", + suffix=".failed", + expr="subcommand", + name="_run_forward", + arg_index=0, + expected_calls=3, + ), +} + +#: `(file, exact substituted expression text)` -> the closed, source-verified +#: set of suffixes a FORWARDED expression can carry. Every entry here was +#: read from the origin, not guessed -- see the module docstring's bucket +#: description. `warn(*skew)`/`warn(*ceiling)` are keyed by the call shape +#: rather than the bare unparsed name, because a `Starred` argument is not a +#: template substitution at all -- it is resolved per CALL SITE inside +#: `_resolve_helper`, not per f-string. +_FORWARDER_SUFFIXES: dict[tuple[str, str], frozenset[str]] = { + # `Issue(f"bootstrap.{refusal.code}", ...)` at bootstrap_cmd.py:2072 + # forwards `check_prerequisites()`'s `PrereqFailure.code` + # (`tan/core/bootstrap.py`), which is exactly one of these four literals + # depending on which refusal branch it returned. + ("tan/commands/bootstrap_cmd.py", "refusal.code"): frozenset( + {"prerequisites-missing", "python-not-runnable", "python-too-old", "venv-unusable"} + ), + # `code=f"bootstrap.{venv_refusal.code}"` at doctor_cmd.py:662 forwards + # the SAME `PrereqFailure`, but `venv_refusal` there is only ever set + # from `posix_venv_unusable()` (doctor_cmd.py:2342) -- a strictly + # narrower value space than the bootstrap_cmd.py forward above. + ("tan/commands/doctor_cmd.py", "venv_refusal.code"): frozenset({"venv-unusable"}), + # `Issue(f"validate.{result.outcome}", ...)` at validate_cmd.py:546 only + # ever fires inside `for message in result.messages`, and + # `outcome = OUTCOME_CLEAN if not messages else OUTCOME_SCHEMA_VIOLATION` + # (validate_cmd.py:272) means a non-empty `messages` implies + # `outcome == OUTCOME_SCHEMA_VIOLATION == "schema-violation"` always. + ("tan/commands/validate_cmd.py", "result.outcome"): frozenset({"schema-violation"}), + # `log.warn(*skew)` / `log.warn(*ceiling)` at bootstrap_cmd.py:2061/:2326 + # unpack the `(suffix, message)` pairs `python_floor_skew_warning()` and + # `python_ceiling_warning()` (`tan/core/bootstrap.py`) return. + ("tan/commands/bootstrap_cmd.py", "warn(*skew)"): frozenset({"python-floor-skew"}), + ("tan/commands/bootstrap_cmd.py", "warn(*ceiling)"): frozenset({"python-newer-than-verified"}), +} + +#: `(file, lineno)` -> why this template is deliberately NOT resolved here, +#: stated rather than silently absent (matching `contract.rs`'s own "KNOWN +#: CEILING" paragraph). See the module docstring's third bucket. Held EMPTY +#: today: `doctor_cmd.py`'s `check.code or f"doctor.{check.name}"` was the +#: sole entry until tan-cli#224's own review measured its actual cost (48 +#: `Check(...)` call sites, ALL literal) and found it resolvable, not a real +#: ceiling -- it moved to `_RESOLVABLE_HELPERS` above. The bucket stays +#: declared, not deleted, so a genuinely out-of-scope FUTURE template has +#: somewhere honest to go rather than forcing a false resolution. +_ACKNOWLEDGED_CEILINGS: dict[tuple[str, int], str] = {} + + +def _calls_matching(tree: ast.Module, *, attr: str | None = None, name: str | None = None) -> list[ast.Call]: + """Every `ast.Call` whose callee is `.attr(` (when `attr` is + given) or a bare `name(` (when `name` is given).""" + out: list[ast.Call] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if attr is not None and isinstance(func, ast.Attribute) and func.attr == attr: + out.append(node) + elif name is not None and isinstance(func, ast.Name) and func.id == name: + out.append(node) + return out + + +def _resolve_helper( + path: pathlib.Path, + *, + kind: str = "prefix", + prefix: str | None = None, + suffix: str | None = None, + attr: str | None = None, + name: str | None = None, + arg_index: int | None = None, + arg_keyword: str | None = None, + expected_calls: int, +) -> tuple[set[str], list[str]]: + """Scan every call to the declared helper in `path`, read the code + argument at `arg_index` (positional) or `arg_keyword`, and return + `(reconstructed codes, unresolved-descriptions)`. `kind="prefix"` + (default) reconstructs `prefix + `; `kind="family"` + reconstructs ` + suffix` (`west_forward_cmd.py`'s + mirrored shape). + + `expected_calls` is asserted EXACTLY, mirroring `PREFIXING_SITES`'s own + pinned per-file counts (`contract.rs:663-682`) for the identical reason: + a floor lets a call site disappear unnoticed as long as enough others + remain to clear it. + + A `Starred` argument (`log.warn(*skew)`, unpacking a 2-tuple rather than + passing the code positionally) is looked up in `_FORWARDER_SUFFIXES` by + `f"{opener}(*{expr})"`; anything else non-literal is reported UNRESOLVED + -- never a silent skip. + """ + opener = attr or name + rel = _rel(path) + tree = _parse(path) + calls = _calls_matching(tree, attr=attr, name=name) + assert len(calls) == expected_calls, ( + f"{rel}: expected {expected_calls} call(s) to {opener}(...), found " + f"{len(calls)} -- a prefixing call site was ADDED (register its code, " + f"then bump this count) or REMOVED (a rename silently stopped this gate " + f"covering an emit). See _resolve_helper's docstring." + ) + parts: set[str] = set() + unresolved: list[str] = [] + for call in calls: + arg: ast.expr | None + if arg_keyword is not None: + arg = next((kw.value for kw in call.keywords if kw.arg == arg_keyword), None) + elif arg_index is not None and len(call.args) > arg_index: + arg = call.args[arg_index] + else: + arg = None + + if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and _is_code_suffix(arg.value): + parts.add(arg.value) + continue + if isinstance(arg, ast.Starred): + key = (rel, f"{opener}(*{ast.unparse(arg.value)})") + declared = _FORWARDER_SUFFIXES.get(key) + if declared is None: + unresolved.append( + f"{rel}:{call.lineno} -- `{opener}(*{ast.unparse(arg.value)})` unpacks a " + f"tuple this scan cannot read a suffix from directly. Add {key!r} to " + f"_FORWARDER_SUFFIXES with the known suffix set, read from source." + ) + else: + parts |= declared + continue + got = ast.unparse(arg) if arg is not None else "no matching argument" + unresolved.append( + f"{rel}:{call.lineno} -- `{opener}(...)`'s code argument is not a literal " + f"({got}). Either pass a literal suffix, or if it forwards a code from " + f"elsewhere, resolve the value space by hand and add it to " + f"_FORWARDER_SUFFIXES." + ) + if kind == "prefix": + assert prefix is not None + return {prefix + s for s in parts}, unresolved + assert suffix is not None + return {s + suffix for s in parts}, unresolved + + +def _classify_and_resolve( + templates: dict[str, list[tuple[int, str, str, str]]], +) -> tuple[set[str], list[str], list[str]]: + """Walk every discovered template, classify it into one of the three + declared buckets, and resolve the ones that are classified. Returns + `(reconstructed codes, unresolved call sites, unclassified templates)`. + """ + codes: set[str] = set() + unresolved: list[str] = [] + unclassified: list[str] = [] + seen_helper_keys: set[tuple[str, int]] = set() + + for rel, sites in templates.items(): + for lineno, literal, expr, kind in sites: + key = (rel, lineno) + if key in _ACKNOWLEDGED_CEILINGS: + continue + if key in _RESOLVABLE_HELPERS: + spec = _RESOLVABLE_HELPERS[key] + spec_kind = spec.get("kind", "prefix") + assert spec_kind == kind, ( + f"{rel}:{lineno} -- _RESOLVABLE_HELPERS declared kind={spec_kind!r} but " + f"the template now reads kind={kind!r}; the f-string changed shape -- " + f"update the declaration." + ) + declared_literal = spec["prefix"] if kind == "prefix" else spec["suffix"] + assert declared_literal == literal and spec["expr"] == expr, ( + f"{rel}:{lineno} -- _RESOLVABLE_HELPERS declared literal={declared_literal!r} " + f"expr={spec['expr']!r} but the template now reads literal={literal!r} " + f"expr={expr!r}; the f-string changed shape -- update the declaration." + ) + seen_helper_keys.add(key) + continue + fwd = _FORWARDER_SUFFIXES.get((rel, expr)) + if fwd is not None: + codes |= {literal + s for s in fwd} if kind == "prefix" else {s + literal for s in fwd} + continue + shape = f'f"{literal}{{{expr}}}"' if kind == "prefix" else f'f"{{{expr}}}{literal}"' + unclassified.append( + f"{rel}:{lineno} -- new prefix template {shape} is not in " + f"_RESOLVABLE_HELPERS, _FORWARDER_SUFFIXES or _ACKNOWLEDGED_CEILINGS. " + f"Classify it in one of the three (see this file's module docstring)." + ) + + missing_helpers = sorted(set(_RESOLVABLE_HELPERS) - seen_helper_keys) + if missing_helpers: + unclassified.append( + f"_RESOLVABLE_HELPERS declares template(s) _prefix_templates no longer finds: " + f"{missing_helpers} -- the f-string moved, was rewritten, or was removed; " + f"update the declaration (and EXPECTED_TEMPLATE_COUNT)." + ) + + for key, spec in _RESOLVABLE_HELPERS.items(): + rel = key[0] + site_codes, site_unresolved = _resolve_helper( + TAN.parent / rel, + kind=spec.get("kind", "prefix"), + prefix=spec.get("prefix"), + suffix=spec.get("suffix"), + attr=spec.get("attr"), + name=spec.get("name"), + arg_index=spec.get("arg_index"), + arg_keyword=spec.get("arg_keyword"), + expected_calls=spec["expected_calls"], + ) + codes |= site_codes + unresolved.extend(site_unresolved) + + return codes, unresolved, unclassified + + +def _all_prefix_templates() -> dict[str, list[tuple[int, str, str, str]]]: + return {_rel(path): _prefix_templates(path) for path in sorted(TAN.rglob("*.py"))} + + +def _all_literal_codes() -> tuple[dict[str, list[str]], list[str]]: + """`(code -> the files it was found in, every unresolved code-position + site across the whole tree)` -- never silently dropped, see + `_literal_codes_in_file`.""" + found: dict[str, list[str]] = {} + unresolved: list[str] = [] + for path in sorted(TAN.rglob("*.py")): + codes, site_unresolved = _literal_codes_in_file(path) + for code in codes: + found.setdefault(code, []).append(_rel(path)) + unresolved.extend(site_unresolved) + return found, unresolved + + +def _missing(emitted: set[str], registered: set[str]) -> list[str]: + """The pure diff both the real gate and its self-test below share, so the + self-test exercises the SAME comparison the real assertion makes rather + than a reimplementation of it.""" + return sorted(emitted - registered) + + +def test_every_prefix_template_is_classified(): + """Non-vacuity + drift pin for auto-discovery itself (tan-cli#224): the + template COUNT is exact, and every template found must resolve to one of + the three declared buckets. A template appearing with none of the three + is the exact hole this file exists to close for a FUTURE prefixing + helper, the same way #224 (and its own review remediation) closed it for + the ones that already existed. + """ + templates = _all_prefix_templates() + total = sum(len(sites) for sites in templates.values()) + found_lines = "\n".join( + (f' {rel}:{lineno} f"{literal}{{{expr}}}"' if kind == "prefix" else f' {rel}:{lineno} f"{{{expr}}}{literal}"') + for rel, sites in templates.items() + for lineno, literal, expr, kind in sites + ) + assert total == EXPECTED_TEMPLATE_COUNT, ( + f'found {total} f-string prefix templates (`f"family.{{code}}"` shape) ' + f"across tan/, expected {EXPECTED_TEMPLATE_COUNT}. Fewer means one was " + f"rewritten (update the count AND check whether a _RESOLVABLE_HELPERS / " + f"_FORWARDER_SUFFIXES / _ACKNOWLEDGED_CEILINGS entry is now stale); more " + f"means a NEW prefixing helper appeared -- classify it in one of the " + f"three buckets (see this file's module docstring), then bump this " + f"count. Found:\n{found_lines}" + ) + _, _, unclassified = _classify_and_resolve(templates) + assert not unclassified, "Unclassified prefix template(s):\n " + "\n ".join(unclassified) + + +def test_every_emitted_issue_code_is_registered(): + """The pair `crates/tan-cli/tests/contract.rs::every_emitted_issue_code_is_registered` + + `::every_prefixed_issue_code_is_registered` ported to the surface that + actually ships (tan-cli#224) -- see the module docstring for the full + design. LITERAL codes and PREFIXED codes are both required to appear in + `contract/issue-codes.json` at some status. + """ + registered = _registered_codes() + + literal, literal_unresolved = _all_literal_codes() + # Never a silent drop (tan-cli#224's own review finding): a code-position + # argument this scan cannot resolve to a literal, and that is not a + # declared forward, is reported by file:line -- not quietly absent from + # `literal` with no trace. + assert not literal_unresolved, ( + f"{len(literal_unresolved)} code-position argument(s) could not be resolved to a " + "literal and are not declared in _KNOWN_CODE_FORWARDS or _FULL_CODE_CALLABLES:\n " + + "\n ".join(literal_unresolved) + ) + # Non-vacuity: a scanner that silently matched nothing would pass this + # gate while checking nothing at all -- the tan-cli#219 failure mode. + assert len(literal) > 30, ( + f"found only {len(literal)} literal issue codes across tan/ -- the scan is " + f"broken, and a broken scan makes this gate vacuous" + ) + + templates = _all_prefix_templates() + prefixed, unresolved, unclassified = _classify_and_resolve(templates) + assert not unclassified, ( + "Unclassified prefix template(s) -- see test_every_prefix_template_is_classified:\n " + + "\n ".join(unclassified) + ) + assert not unresolved, ( + f"{len(unresolved)} prefixed emit site(s) could not be resolved:\n " + "\n ".join(unresolved) + ) + + emitted = set(literal) | prefixed + missing = _missing(emitted, registered) + assert not missing, ( + f"{len(missing)} issue code(s) are emitted by python/tan but appear in " + "contract/issue-codes.json at NO status:\n" + + "\n".join( + f" {c} (sites: {', '.join(literal.get(c, ['assembled by a prefixing helper']))})" for c in missing + ) + + "\n\nAn unregistered code is ungated on both sides of the seam at once: this " + "repo's registry-driven checks never see it, and the published " + "envelope-contract.json is built from that same registry, so alp-sdk-vscode " + 'cannot see it either. Add each one with "status": "reserved" and ' + '"consumer": "none" -- that costs nothing, since a reserved code may ' + 'still be renamed freely. Use "frozen" ONLY once a consumer actually binds ' + "to it." + ) + + +def test_gate_rejects_a_deliberately_unregistered_code(): + """tan-cli#275's own lesson, applied to this gate specifically: an + assertion nobody has ever watched fail is not proven to fire. This test + is that watch -- it exercises the SAME `_missing` comparison + `test_every_emitted_issue_code_is_registered` makes, against the REAL + registry, with one fabricated code injected into the emitted set. + + Manually verified both ways while writing this file (not just asserted + here): with the fabricated code removed from the injected set the + assertion below fails (`AssertionError`), and restored it passes -- so + this genuinely exercises the failure path, not a tautology that can + never go red. + """ + registered = _registered_codes() + real_emitted = set(_all_literal_codes()[0]) + # Not a real code -- guaranteed absent from the registry both by + # construction (this spelling names itself as one) and because a real + # code always has a plausible family; "zzz-tan-cli-224-self-test" is + # neither. + fabricated = "zzz-tan-cli-224-self-test.never-registered" + assert fabricated not in registered, "the fabricated self-test code collided with a real one -- pick another" + + injected = real_emitted | {fabricated} + offenders = _missing(injected, registered) + assert fabricated in offenders, ( + "the gate's own diff did not flag a deliberately unregistered code -- " + "this gate cannot fail, which per tan-cli#275 means it is not a gate" + ) + + # And the negative: with nothing injected, that same fabricated spelling + # must NOT appear -- proving the assertion is sensitive to the input, + # not unconditionally red. + offenders_clean = _missing(real_emitted, registered) + assert fabricated not in offenders_clean + + +def test_prefix_template_scan_finds_a_fresh_synthetic_site(tmp_path: pathlib.Path): + """A second self-test, at the AST-mechanics level rather than the + registry-diff level: [`_prefix_templates`] and [`_literal_codes_in_file`] + are exercised here against SYNTHETIC source containing codes neither has + ever seen. Proves the SCANNER notices a new site, not just that the diff + logic notices a missing registration. + + Written under pytest's own `tmp_path`, NOT under `TAN` (tan-cli#224 + review): `_literal_codes_in_file`/`_prefix_templates` take an arbitrary + path (`_rel` falls back to the path unchanged outside `tan/`, see its own + docstring), so nothing here needs to live inside the real package -- and a + scratch file that DID would be one interrupted run away from surviving + into `python/tan/` itself (a stray file in the very tree every other test + in this file globs with `TAN.rglob("*.py")`), the production-tree escape + this fix closes. + """ + synthetic = tmp_path / "selftest_scratch.py" + synthetic.write_text( + "from __future__ import annotations\n" + "\n" + 'SELFTEST_CONST = "selftest.const-code"\n' + "\n" + "\n" + "def emit_one(reg):\n" + ' return Issue(SELFTEST_CONST, "error", "x")\n' + "\n" + "\n" + "def emit_two(code):\n" + ' return Issue(f"selftestfamily.{code}", "error", "x")\n' + "\n" + "\n" + "def emit_three():\n" + ' return Check("n", "fail", "d", code="selftest.kwarg-code")\n', + encoding="utf-8", + ) + literal, unresolved = _literal_codes_in_file(synthetic) + assert literal == {"selftest.const-code", "selftest.kwarg-code"}, literal + assert unresolved == [], unresolved + + templates = _prefix_templates(synthetic) + assert templates == [(11, "selftestfamily.", "code", "prefix")], templates From d9a8daf46c0fa4a0cefdb4dac531d97557d19b32 Mon Sep 17 00:00:00 2001 From: Caner Alp <109098482+alpCaner@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:55:34 +0000 Subject: [PATCH 05/10] test(parity): stop frozen fixtures encoding the capture host's tool inventory (#313, #324) Eight parity cases branch on a which() probe, so their frozen answers record which tools the CAPTURE host had -- and the Python side is then compared live against the REPLAY host. The two sides were not measuring the same environment, so the comparison measured the host rather than the port. #313: three yocto_wic cases froze 'would run dd if=...', because the capture host had dd and no bmaptool. plan_yocto_wic prefers bmaptool when planning only and dd is absent, so on a host with neither the live Python side picked bmaptool and the three failed. Green wherever dd exists, hence green in CI. #324: three west_forward cases froze the 'west not found on PATH' launch error, because the capture host had no west. On a host that HAS west the live Python side genuinely launches it and reports the child's failure instead. Same bug, opposite direction. Neither is a port gap. Run both binaries under a sanitised environment and they agree byte for byte -- same command, same exitCode, same data, same issue code, same message. The port already carries the identical check and the identical string. Replay now pins the Python side's PATH to the recorded inventory. Because that side runs as a subprocess, an in-process monkeypatch cannot reach it, so the pin crosses the process boundary as a scratch PATH. #324 reuses the mechanism #313 introduced rather than adding a second one, generalised to the absent-tool case as oracle.empty_tool_inventory. Under TAN_PARITY_LIVE=1 both sides genuinely spawn, and compare() REFUSES a python_env_overrides rather than silently comparing one pinned side against one unpinned side -- which would be a fresh divergence, not a capture. PROVENANCE.txt now records the capture host's inventory and the amended re-capture recipe, because the recipe at the top of that file hard-errors for these eight by design. Capturing on a host with west installed and the pin dropped would freeze an answer that only replays on hosts that also have west, reintroducing exactly the bug #324 closed. --- python/tests/parity/oracle.py | 93 +++++++++++++++- .../parity/oracle_fixtures/PROVENANCE.txt | 41 +++++++ .../tests/parity/test_flash_oracle_parity.py | 103 +++++++++++++++++- python/tests/parity/test_oracle_parity.py | 24 +++- 4 files changed, 251 insertions(+), 10 deletions(-) diff --git a/python/tests/parity/oracle.py b/python/tests/parity/oracle.py index 4f066bd4..3dfd7fec 100644 --- a/python/tests/parity/oracle.py +++ b/python/tests/parity/oracle.py @@ -243,6 +243,32 @@ def missing_for_live(rust: str | None) -> bool: return oracle_fixtures.LIVE and rust is None +def empty_tool_inventory(scratch: Path) -> str: + """A ``PATH`` value pointing at one EMPTY scratch directory -- the general + form of ``test_flash_oracle_parity.py``'s ``_pin_tool_inventory`` for a + case whose frozen fixture answer is "no such tool anywhere on PATH", not + "found this specific stand-in". Any ``shutil.which``/``doctor_cmd.on_path`` + probe run against this directory alone reports every name absent, + matching whatever tool inventory a fixture's capture host happened to + lack (tan-cli#313, tan-cli#324) -- ``west`` for + ``test_west_forward_matches_rust`` today, and any other which()-gated + case's absent-tool branch tomorrow, without inventing a second + bespoke per-file helper for each one that comes up. + + REPLACES ``PATH`` outright rather than prepending, for the identical + reason ``_pin_tool_inventory`` does: prepending would still let a REAL + tool further down the replay host's own PATH be found, which is exactly + the host-dependence this exists to remove. Hand the result to + :func:`compare`'s ``python_env_overrides`` -- e.g. + ``{"PATH": empty_tool_inventory(tmp_path)}`` -- never fabricate the + equivalent for the rust side; see that parameter's own docstring for why + forwarding a PATH pin to the oracle is not a safe substitute. + """ + stub_dir = scratch / "empty-path" + stub_dir.mkdir(exist_ok=True) + return str(stub_dir) + + def python_command() -> list[str]: """The port under test. Defaults to the source tree so the harness runs without a packaging step; ``TAN_PYTHON_BINARY`` points it at the PyInstaller @@ -270,7 +296,22 @@ def _env(home: Path) -> dict[str, str]: } -def _run(command: list[str], argv: list[str], cwd: Path, home: Path): +def _run( + command: list[str], + argv: list[str], + cwd: Path, + home: Path, + *, + env_overrides: dict[str, str] | None = None, +): + """``env_overrides`` layers on top of :func:`_env`'s shared environment -- + e.g. pinning ``PATH`` so a tool-presence probe INSIDE the spawned process + (``shutil.which``/``doctor_cmd.on_path``) cannot pick up whatever happens + to be installed on whichever host is replaying this suite (tan-cli#313). + Empty by default, so every existing caller is unaffected.""" + env = _env(home) + if env_overrides: + env = {**env, **env_overrides} proc = subprocess.run( [*command, *argv], capture_output=True, @@ -281,7 +322,7 @@ def _run(command: list[str], argv: list[str], cwd: Path, home: Path): encoding="utf-8", errors="replace", cwd=cwd, - env=_env(home), + env=env, ) try: payload = json.loads(proc.stdout) @@ -355,6 +396,7 @@ def compare( home: Path | None = None, python: list[str] | None = None, extra_scrub_roots: tuple[Path | str, ...] = (), + python_env_overrides: dict[str, str] | None = None, ) -> ParityResult: """Diff the two binaries on ``argv``, scoped to ``surface``. @@ -374,12 +416,57 @@ def compare( cover -- e.g. a checked-in fixture file (a ``--plan-from`` plan) that itself embeds the path of whatever alp-sdk checkout it was captured against. Empty by default, so every existing caller is unaffected. + + ``python_env_overrides`` (tan-cli#313) layers extra environment onto the + PYTHON side's subprocess ONLY, never the rust side's -- and is REFUSED + outright whenever ``oracle_fixtures.LIVE`` is set, rather than silently + applied to one side only. In frozen replay (the default, no + ``TAN_PARITY_LIVE``) the rust side never spawns anything at all, so + pinning only the python side is exactly right: the frozen fixture IS the + rust answer, already captured against a specific tool inventory, and the + python side's live PATH probe needs pinning to match it, or the + comparison depends on what happens to be installed on whoever runs the + suite. See ``test_flash_oracle_parity.py``'s ``_pin_tool_inventory``. + + Under ``TAN_PARITY_LIVE=1`` both sides DO spawn, and ``_env``'s own + invariant applies in full ("one environment, shared by both sides -- + the whole point is that the only difference between the two runs is the + implementation"): applying the pin to python alone would silently break + that invariant for exactly the pinned cases, and forwarding it to + ``rust_run`` too is NOT a safe substitute -- measured against the real + oracle (``target/debug/tan``), POSIX ``command_on_path`` + (``crates/tan-cli/src/util.rs:45-50``) resolves a tool by SPAWNING + ``which`` as its own subprocess, which itself has to be found via the + (now-pinned, ``which``-free) ``PATH`` -- so a PATH replaced with only a + ``dd`` stand-in makes ``which`` itself unresolvable, every rust-side + probe report "not found" including ``dd``, and the SAME pin that makes + python answer "dd" makes rust answer "bmaptool" -- the opposite tool, on + the identical override. So under ``TAN_PARITY_LIVE=1`` this parameter + raises rather than comparing two binaries under two different (or two + subtly-broken-in-opposite-directions) environments; the caller should + drop the pin for a live run (both binaries then share this host's REAL + tool inventory, which is the whole point of a live re-validation) and + keep it only for frozen replay. ``None`` by default, so every existing + caller is unaffected. """ home = home or cwd roots = (cwd, home, *extra_scrub_roots) + if oracle_fixtures.LIVE and python_env_overrides: + raise RuntimeError( + "compare() got python_env_overrides under TAN_PARITY_LIVE=1: both " + "binaries spawn for real in this mode, and pinning only the " + "python side's PATH breaks _env's 'one environment, shared by " + "both sides' invariant -- see this parameter's own docstring for " + "why forwarding the same pin to the rust side is not a safe fix " + "either (tan-cli#313). Drop python_env_overrides for a live run, " + "or drop TAN_PARITY_LIVE to replay the frozen fixture with it." + ) + r_code, r_out = rust_run(argv, cwd, home, scrub_roots=roots) - p_code, p_out = _run(python or python_command(), argv, cwd, home) + p_code, p_out = _run( + python or python_command(), argv, cwd, home, env_overrides=python_env_overrides + ) p_out = oracle_fixtures.scrub(p_out, *roots) # Scoped to PATH_KEYS, on BOTH sides, before the diff -- see that # constant's own docstring. A no-op whenever the two sides already agree diff --git a/python/tests/parity/oracle_fixtures/PROVENANCE.txt b/python/tests/parity/oracle_fixtures/PROVENANCE.txt index 6a241550..41133008 100644 --- a/python/tests/parity/oracle_fixtures/PROVENANCE.txt +++ b/python/tests/parity/oracle_fixtures/PROVENANCE.txt @@ -162,3 +162,44 @@ returns nothing, `tests/gates/test_no_leaked_host_paths.py` passes with every file in this directory (and this file's own new `test_oracle_fixtures.py`) tracked, and the other four fixture files in this directory are byte- identical to before this pass (`git diff --stat` on each is empty). + +-------------------------------------------------------------------------- + +THE CAPTURE HOST'S TOOL INVENTORY IS PART OF THE FROZEN ANSWER +(tan-cli#313, tan-cli#324) + +Eight cases branch on a `which()` probe, so their frozen answers encode which +tools the CAPTURE host had -- not just what the oracle computes. The capture +host had: + + west ABSENT -- the three west_forward cases froze the + "west not found on PATH" launch error + dd PRESENT -- the yocto_wic cases froze "would run dd if=..." + bmaptool ABSENT -- else those cases would have frozen a bmaptool argv + +Replay pins the PYTHON side's PATH to match that inventory, so the comparison +does not silently measure the replay host instead of the port. The pins: + + test_flash_oracle_parity.py `_TOOL_PROBE_PINNED_CASES` (5 cases) -> + `_pin_tool_inventory`, a scratch PATH holding a stand-in `dd` only + test_oracle_parity.py `test_west_forward_matches_rust[migrate|lock|quality]` + (3 cases) -> `oracle.empty_tool_inventory`, a scratch PATH holding nothing + +CONSEQUENCE FOR RE-CAPTURE, and the reason this section exists: the recipe at +the top of this file NO LONGER WORKS AS WRITTEN for these eight. Under +TAN_PARITY_LIVE=1 both sides genuinely spawn, so `compare()` REFUSES a +`python_env_overrides` rather than compare one pinned side against one +unpinned side -- deliberately, since that would be a fresh divergence rather +than a capture. Measured: + + TAN_PARITY_LIVE=1 python -m pytest \ + "tests/parity/test_oracle_parity.py::test_west_forward_matches_rust" + -> 3 failed, RuntimeError: compare() got python_env_overrides under + TAN_PARITY_LIVE=1 + +To re-capture any of the eight, run the capture on a host whose tool +inventory MATCHES the table above (no `west`, no `bmaptool`, a real `dd`) and +drop the pin for that run. Capturing on a host with `west` installed and the +pin dropped would freeze a different answer entirely -- one that then only +replays on hosts that also have `west`, reintroducing exactly the bug +tan-cli#324 closed. diff --git a/python/tests/parity/test_flash_oracle_parity.py b/python/tests/parity/test_flash_oracle_parity.py index 9a100f87..137d8f5c 100644 --- a/python/tests/parity/test_flash_oracle_parity.py +++ b/python/tests/parity/test_flash_oracle_parity.py @@ -405,6 +405,76 @@ def work_dir(tmp_path_factory): _HOST_ANCHORED_ABSOLUTE_CASES = frozenset({"absolute-artefact-passes-through"}) +#: Case IDs whose expected message (or whose pass/fail shape) depends on a +#: LIVE tool-presence probe against PATH -- either `plan_yocto_wic`'s own +#: `which("bmaptool")`/`which("dd")` (`tan/core/flash_plan.py:1002-1056`), or +#: the required-tool gate `tool_gate` (`flash_plan.py:1524-1545`, reached via +#: `doctor_cmd.on_path` at `flash_cmd.py:879-882`). The frozen fixture +#: recorded whichever tool inventory the CAPTURE host happened to have -- +#: `dd` present/`bmaptool` absent for the three yocto cases, `west` absent +#: for the two `zephyr_west_flash` ones below -- so replaying on a host with +#: a DIFFERENT inventory (this box, for one: real `dd`, no `bmaptool`, and a +#: broken but PATH-resolvable `west` shim) makes the port pick/find a +#: different tool and diffs on text that is not a port bug at all +#: (tan-cli#313). +#: +#: `tool_gate` is bypassed ONLY under `--dry-run` or an empty `requires` +#: (its own docstring) -- it is LIVE for every other case that reaches it. +#: That is NOT every other case in `CASES`, though: three more non-dry-run +#: cases never reach `tool_gate` at all, refused by an earlier check in +#: `flash_cmd.py`'s dispatch order (traced directly, not inferred): +#: * `no-artefact-real-run-fails` -- fails the empty-artefact check +#: BEFORE `tool_gate` (`flash_cmd.py:856-860`). +#: * `flash-args-tbd-mapping` -- skips on `flash_args_has_tbd` BEFORE +#: `tool_gate` (`flash_cmd.py:811-817`). +#: * `sdk-root-invalid` -- refused at SDK-root resolution, before the +#: manifest is even read (`flash_cmd.py:1163-1175`), nowhere near a +#: backend or `tool_gate`. +#: See `_pin_tool_inventory` for why the five pinned below DO need it. +_TOOL_PROBE_PINNED_CASES = frozenset( + { + "empty-boot-order-sorts-and-helpers-last", + "yocto-unconfirmed-is-planned-not-ok", + "yocto-alias-method-resolves", + "missing-tool-fails", + "missing-tool-skips-with-flag", + } +) + + +def _pin_tool_inventory(work_dir) -> str: + """A scratch PATH holding exactly one stand-in tool, `dd` -- matching what + the fixture's capture host had (`dd`, no `bmaptool`, no `west`) -- for + `python_env_overrides` to hand to the PYTHON side alone (tan-cli#313). + + Serves two different probes with the one stub dir: `plan_yocto_wic`'s + `which("bmaptool")`/`which("dd")` (the three yocto case IDs), where `dd` + being FOUND is the point, and `tool_gate`'s `west` probe (the two + `missing-tool-*` case IDs), where `dd`'s presence is irrelevant and + `west` simply not being anywhere on this replaced PATH is what the + frozen "none found" answer needs. + + Replaces PATH outright rather than prepending: `doctor_cmd.on_path` + (what `plan_yocto_wic`'s and `tool_gate`'s `which` callables both + resolve to) walks every directory looking for a name match, so + prepending a `dd`-only directory ahead of the replay host's real PATH + would still let a REAL `bmaptool` or `west` further down it be found -- + which is exactly the host-dependence this fixes. The stub's own content + is never read: every case that pins this is a `--dry-run` preview, an + unconfirmed "would run" plan, or the required-tool gate's own refusal/ + skip message, so `dd` is only ever named in a message, never spawned. + `chmod` is a no-op on Windows (no execute bit there), where + `os.access(path, os.X_OK)` accepts any existing file -- covered by + `doctor_cmd.on_path`'s own docstring. + """ + stub_dir = work_dir / "tool-stub" + stub_dir.mkdir() + dd_stub = stub_dir / "dd" + dd_stub.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + dd_stub.chmod(0o755) + return str(stub_dir) + + @pytest.mark.parametrize("case_id, manifest, extra", CASES, ids=[c[0] for c in CASES]) def test_flash_matches_the_rust_oracle(case_id, manifest, extra, work_dir): if case_id in _HOST_ANCHORED_ABSOLUTE_CASES and not oracle_fixtures.REPLAY_IS_CAPTURE_PLATFORM: @@ -417,7 +487,16 @@ def test_flash_matches_the_rust_oracle(case_id, manifest, extra, work_dir): (work_dir / "build" / "system-manifest.yaml").write_text( manifest, encoding="utf-8", newline="" ) - result = compare(_argv(extra), work_dir, surface=ENVELOPE, home=work_dir) + python_env_overrides = ( + {"PATH": _pin_tool_inventory(work_dir)} if case_id in _TOOL_PROBE_PINNED_CASES else None + ) + result = compare( + _argv(extra), + work_dir, + surface=ENVELOPE, + home=work_dir, + python_env_overrides=python_env_overrides, + ) assert result.matches, f"{case_id}: " + "; ".join(result.diffs) @@ -470,6 +549,22 @@ def test_a_real_spawn_diffs_including_the_captured_failure_tail(work_dir): Also skipped where the local `dd` is not the implementation the fixture captured -- see `_dd_matches_the_captured_implementation`. + + Also skipped where `bmaptool` IS present: `plan_yocto_wic`'s + `if bmaptool or (planning_only and not dd)` (`flash_plan.py:1023-1026`) + picks `bmaptool` whenever it is found on PATH, unconditionally -- + `planning_only` narrows nothing here, since the check is a bare `or`. + This is an ordinary Yocto dev host (`apt install bmap-tools` is the + documented way to get the preferred tool), not an exotic one, and + unlike the yocto CASES above this test is not in `_TOOL_PROBE_PINNED_CASES` + and cannot be: it actually SPAWNS the resolved tool (that is the test's + whole subject, its captured stderr tail), and a `dd` stand-in that only + NAMES the tool would defeat that -- pinning would need a stub `dd` + faithful enough to reproduce the fixture's exact captured failure text, + which is just `_dd_matches_the_captured_implementation`'s own job + restated. Measured: with a `bmaptool` stub on PATH the python side spawns + it (exits `rc=1`, no captured tail) while the frozen fixture still names + `dd`'s tail -- tan-cli#313 at this call site too. """ if shutil.which("dd") is None: pytest.skip("no `dd` on PATH; nothing to spawn") @@ -480,6 +575,12 @@ def test_a_real_spawn_diffs_including_the_captured_failure_tail(work_dir): "to open '': No such file`); the tail this test compares is the " "SPAWNED TOOL's text, not tan's, so that diff is not a port defect" ) + if shutil.which("bmaptool") is not None: + pytest.skip( + "a host with bmaptool on PATH plans that tool instead of dd " + "(flash_plan.py:1023-1026 prefers it unconditionally); this test's " + "subject is the SPAWNED dd's captured failure tail, not bmaptool's" + ) (work_dir / "build" / "system-manifest.yaml").write_text( _slice("yocto_wic", "{target: /dev/sdb}"), encoding="utf-8", newline="" ) diff --git a/python/tests/parity/test_oracle_parity.py b/python/tests/parity/test_oracle_parity.py index 232e4ec6..250f7fbc 100644 --- a/python/tests/parity/test_oracle_parity.py +++ b/python/tests/parity/test_oracle_parity.py @@ -39,6 +39,7 @@ VERSION, _run, compare, + empty_tool_inventory, missing_for_live, narrow_plan, normalise_path_separators, @@ -205,11 +206,17 @@ def test_west_forward_matches_rust(verb, work_dir, tmp_path): so `data.westCwd` actually goes through the workspace-walk branch (not just the already-posix `--project` echo) -- the branch where a bare `str(PathLikeObject)` re-renders with the platform separator on Windows - and breaks the envelope's platform-identical-path contract. Neither side - has a real `west` on PATH here, so both report the same launch-error - envelope; that error envelope still carries `data.westCommand`/`westCwd`/ - `args`, which is exactly what a westCwd or args-capture regression would - move. + and breaks the envelope's platform-identical-path contract. The frozen + fixture was captured on a host with no `west` on PATH at all, so the rust + side's (frozen) answer is the "west not found on PATH" launch error; + `python_env_overrides` pins the PYTHON side's PATH to match that same + absence, rather than whatever this replay host happens to have installed + -- on any host with a PATH-resolvable `west`, working or not, the python + side would otherwise genuinely launch it and diverge on ITS output + instead of reporting the same launch error (tan-cli#324; the identical class of bug + `_pin_tool_inventory` fixed for the yocto_wic flash cases, tan-cli#313). + That error envelope still carries `data.westCommand`/`westCwd`/`args`, + which is exactly what a westCwd or args-capture regression would move. """ (work_dir / ".west").mkdir() # `--format json` sits BEFORE the forwarded `--core`/`-b` flags on purpose: @@ -232,7 +239,12 @@ def test_west_forward_matches_rust(verb, work_dir, tmp_path): "-b", "some_board", ] - result = compare(argv, cwd=work_dir, home=tmp_path / "home") + result = compare( + argv, + cwd=work_dir, + home=tmp_path / "home", + python_env_overrides={"PATH": empty_tool_inventory(tmp_path)}, + ) assert result.matches, "\n".join(result.diffs) From ce127c2786b9a4d72146bb21a1bce6108fcb2bfa Mon Sep 17 00:00:00 2001 From: Caner Alp <109098482+alpCaner@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:55:51 +0000 Subject: [PATCH 06/10] test: scrub ZEPHYR_BASE in the shared fixture, and read REAL_ENVIRON in the native-sim e2e The autouse _scrub_sdk_discovery_env fixture isolated ALP_SDK_ROOT, SOURCE_DATE_EPOCH, HOME and USERPROFILE but left ZEPHYR_BASE inherited from the developer's shell, so one more discovery input could differ between a local run and CI. It is scrubbed now. test_native_sim_e2e's _zephyr_base() read os.environ directly, which would have defeated that scrub; it reads REAL_ENVIRON instead, matching the idiom the same file already uses for the real west build subprocess env. Verified with and without a hostile ZEPHYR_BASE exported: byte-identical counts either way across the doctor, kconfig and native-sim suites. This is NOT a fix for #297, which stays open. That issue's diagnosis -- that an ambient ZEPHYR_BASE makes test_west_resolved_reproduces_and_closes_tan_ cli_123 flaky -- does not survive measurement: find_workspace_venv's upward .venv walk resolves before the ZEPHYR_BASE read is ever reached, and the test plants its .venv at the subprocess's own cwd. With a hostile value set, that test gives 5 passed / 128 deselected, identical to the unset baseline. The original Windows-side failure remains unreproduced and needs a fresh repro on Windows. --- python/tests/commands/test_native_sim_e2e.py | 8 +++++++- python/tests/conftest.py | 11 ++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/python/tests/commands/test_native_sim_e2e.py b/python/tests/commands/test_native_sim_e2e.py index 31a613eb..b34ac5a4 100644 --- a/python/tests/commands/test_native_sim_e2e.py +++ b/python/tests/commands/test_native_sim_e2e.py @@ -91,7 +91,13 @@ def _zephyr_base() -> Path | None: - raw = os.environ.get("ZEPHYR_BASE") + # From `REAL_ENVIRON` (captured at collection time in `tests/conftest.py`), + # not `os.environ` read here -- the autouse `_scrub_sdk_discovery_env` + # fixture now deletes `ZEPHYR_BASE` from the process environment ahead of + # every test function, so an `os.environ` read from inside this module's + # test body/helpers (called after that fixture has run) would always see + # it gone, even on a host where a real Zephyr workspace is exported. + raw = REAL_ENVIRON.get("ZEPHYR_BASE") if not raw: return None base = Path(raw) diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 82d57c3c..e589bd74 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -10,7 +10,9 @@ developer who follows the documented `export ALP_SDK_ROOT=` onboarding, or who has ever run `tan sdk switch --global`, gets a DIFFERENT SDK resolved than a clean CI runner would, and a test asserting "nothing -resolves" observes a real checkout instead. +resolves" observes a real checkout instead. `ZEPHYR_BASE` gets the same +treatment for the same reason: a developer/CI shell's own value must not +decide what a test observes any more than `ALP_SDK_ROOT` does. Autouse, function-scoped, and applied to every test in this tree -- both in-process calls (`resolve_sdk_root_ladder` et al., called directly) and the @@ -86,6 +88,13 @@ def sdk_root() -> Path | None: @pytest.fixture(autouse=True) def _scrub_sdk_discovery_env(tmp_path_factory, monkeypatch): monkeypatch.delenv("ALP_SDK_ROOT", raising=False) + # A developer/CI shell's own `$ZEPHYR_BASE` must not decide what a test + # observes any more than `ALP_SDK_ROOT` does -- left unscrubbed, a test + # asserting "no ZEPHYR_BASE workspace resolved" instead sees a real one. + # `test_native_sim_e2e.py` needs the REAL value for its actual `west + # build` subprocess; it reads that from `REAL_ENVIRON` above, not from + # `os.environ` inside the test body, for exactly this reason. + monkeypatch.delenv("ZEPHYR_BASE", raising=False) # `SOURCE_DATE_EPOCH` wins over the clock in `tan.core.timestamp`, so a # developer or CI image that exports it (reproducible-build setups do) # changes every `generatedAt`/`updatedAt` this suite observes -- and a From a903d9495da2650104cd4cac5036be681add2629 Mon Sep 17 00:00:00 2001 From: Caner Alp <109098482+alpCaner@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:56:00 +0000 Subject: [PATCH 07/10] ci(parity): name the repository_dispatch run from client_payload.sdk_ref (#213) client_payload.sdk_ref was read into a step output and echoed via ::notice::, which is job-log-only -- it never reached the Actions API display_title field alp-sdk polls to correlate the run it triggered. A top-level run-name key surfaces it. The non-dispatch arm is an empty string, not a formatted event name: GitHub falls back to the event-specific default title when run-name is empty or whitespace, so push and pull_request runs keep showing their commit message or PR title. Naming the event there instead would have overwritten that default on every such run -- a regression traded for the fix. Same idiom this file already uses one line away for ref:. Inert until it reaches the default branch. --- .github/workflows/parity.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 63485088..cef97214 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -23,6 +23,21 @@ name: parity +# tan-cli#213: surface `client_payload.sdk_ref` in the Actions API's own +# `display_title` (what `run-name` sets) rather than only a job-log `::notice::` +# (see the "resolve alp-sdk ref" step below) -- alp-sdk's dispatch-confirmation +# poll can then filter on the exact ref it sent instead of "any run created +# after our dispatch epoch", which a concurrent push or other sender also +# satisfies. Only `repository_dispatch` carries `client_payload`; the other +# three triggers below (`push`, `pull_request`, and `workflow_call` from +# release.yml) render an empty string instead, which is NOT a blank/broken +# title -- GitHub's own rule is that an omitted-or-whitespace-only `run-name` +# falls back to the event-specific default (the commit message on `push`, the +# PR title on `pull_request`), and that default is strictly more informative +# than a constant `parity (push)` string would be. Same empty-string-means- +# "use the default" idiom as the `ref:` step below. +run-name: ${{ github.event_name == 'repository_dispatch' && format('parity (sdk {0})', github.event.client_payload.sdk_ref) || '' }} + on: # Direct pushes to `main` (an admin merge, a hotfix, a back-merge) open no PR, # so without this the three jobs below never ran on those commits at all -- From bee4c6a333674bb7c892946bf7bc1662b9715fc9 Mon Sep 17 00:00:00 2001 From: Caner Alp <109098482+alpCaner@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:21:47 +0000 Subject: [PATCH 08/10] test(gates): key the issue-code gate on stable identifiers, not line numbers (#224) The gate landed keyed on (file, ABSOLUTE LINE), which reds the build on any unrelated edit that shifts a declared site. It happened immediately: dev's fc88ca1 added +69/-18 lines to bootstrap_cmd.py, moving f"bootstrap.{code}" from :1522 to :1532, and both the `python` and `seam1 -- plan-shape parity` CI jobs went red on the PR's merge commit while the branch itself was green. Re-pinning the number was the wrong fix. A gate that must be hand-re-pinned after every unrelated edit trains reviewers to update the pin mechanically without reading what changed, which is how a gate stops gating -- the tan-cli#275 lesson this file already cites. Keys are now (file, enclosing qualname). Line numbers stay in the error messages, where a human needs them, and appear in no hand-maintained key. Proved by measurement: shift bootstrap_cmd.py, gate still passes; plant an unregistered code, gate still fails naming it. Re-keying opened a smaller hole, found by review and reproduced: a SECOND template inside an already-declared function collapses onto one key, leaving only the scalar EXPECTED_TEMPLATE_COUNT -- whose own failure message says to bump the count. So each declaration now pins how many sites it covers, and a mismatch fails naming the qualname, both counts and every matched file:line. _FORWARDER_SUFFIXES, the third bucket with the same many-to-one shape, gets the same discipline. _check_site_counts was a closure and could not be tested. This file states its own bar -- an assertion nobody has ever watched fail is not proven to fire -- and met it for its two older assertions. It is extracted and tested now, including the _ACKNOWLEDGED_CEILINGS half that is empty in production and had never had a real value driven through it. One hole is documented rather than closed. Resolving an f-string statically cannot tell a substituted name bound by the enclosing function's parameter list from one rebound by a comprehension target of the same spelling, so a 1-for-1 swap at a declared key needs no count bump at all and stays green. That is a limit, not a missing pin; chasing it means shadowing heuristics that false-positive on ordinary Python. The module docstring records it as an acknowledged ceiling with the verbatim repro and names the real backstop: contract/issue-codes.json is read directly by release.yml when building the published envelope-contract.json, and alp-sdk-vscode fails open on a code it does not recognise, so the cost is a silently-ignored issue, not a crash. Also drops three line-number comments that had themselves rotted (2072, 2061, 2326 -> 2099, 2088, 2363) rather than re-measuring them, in the file whose whole subject is that line identity decays. --- .../test_every_issue_code_is_registered.py | 651 ++++++++++++++---- 1 file changed, 536 insertions(+), 115 deletions(-) diff --git a/python/tests/gates/test_every_issue_code_is_registered.py b/python/tests/gates/test_every_issue_code_is_registered.py index 9ce17aff..f9595d48 100644 --- a/python/tests/gates/test_every_issue_code_is_registered.py +++ b/python/tests/gates/test_every_issue_code_is_registered.py @@ -80,28 +80,90 @@ functions prefix would either miss a new one silently or invent codes from unrelated calls"): - * `_RESOLVABLE_HELPERS`, keyed by `(file, lineno)` of the f-string itself -- - the substituted name is a plain parameter of the enclosing - function/method (`kind="prefix"`, the fixed literal is a PREFIX) or, - mirrored, a parameter of the function the f-string's SUBSTITUTED family - comes from while the SUFFIX is fixed (`kind="family"`, - `west_forward_cmd.py`'s `f"{subcommand}.failed"`) -- either way, every - call site of that one function is scanned, and a literal argument there - IS the missing half. Also covers a constructor whose call sites are - scanned the same way even though the substitution is not literally the - enclosing function's own parameter: `doctor_cmd.py`'s - `f"doctor.{check.name}"` resolves by scanning every `Check(...)` - construction's `name` (48 call sites, all literal -- MEASURED, not - assumed, while closing tan-cli#224's own review). A call passing - something else (a `Name`, an `Attribute`, a `Starred` unpack) is - unresolved unless it also appears in `_FORWARDER_SUFFIXES`. + * `_RESOLVABLE_HELPERS`, keyed by `(file, enclosing qualname)` -- the + dotted name of the function/method the f-string ITSELF sits inside + (`Log.take_issues`, `_refusal`, `validate.fail`), tracked by + [`_prefix_templates`] while it walks. DELIBERATELY NOT the f-string's + line number, which an earlier version of this table used and which broke + for real, not hypothetically: dev's fc88ca1 shifted `_refusal`'s own + template from :1522 to :1532 by adding lines earlier in the same file -- + changing nothing about `_refusal` itself -- and reddened this gate on an + unrelated, already-merged PR. A qualname is immune to that: it only + changes when the SITE itself is renamed, moved to a different function, + or rewritten, all of which genuinely warrant updating this table. It + still disambiguates every case a bare `(file, prefix, expr)` key could + not: `bootstrap_cmd.py` has TWO distinct templates that both substitute a + parameter literally named `code`, but one sits in `Log.take_issues` and + the other in `_refusal`, so the qualname alone tells them apart. The + substituted name is a plain parameter of the enclosing function/method + (`kind="prefix"`, the fixed literal is a PREFIX) or, mirrored, a + parameter of the function the f-string's SUBSTITUTED family comes from + while the SUFFIX is fixed (`kind="family"`, `west_forward_cmd.py`'s + `f"{subcommand}.failed"`) -- either way, every call site of that one + function is scanned, and a literal argument there IS the missing half. + Also covers a constructor whose call sites are scanned the same way even + though the substitution is not literally the enclosing function's own + parameter: `doctor_cmd.py`'s `f"doctor.{check.name}"` resolves by + scanning every `Check(...)` construction's `name` (48 call sites, all + literal -- MEASURED, not assumed, while closing tan-cli#224's own + review). A call passing something else (a `Name`, an `Attribute`, a + `Starred` unpack) is unresolved unless it also appears in + `_FORWARDER_SUFFIXES`. Two templates that share one qualname + (`west_forward_cmd.py`'s `_run_forward`, whose success and `OSError` arms + both build the identical `f"{subcommand}.failed"`) collapse to ONE + declared entry, not two -- they are the same resolvable site scanned + once, not two coincidentally-identical declarations to keep in sync by + hand. That collapse is exactly what a REVIEWER later showed was a hole, + not just an economy: because the key is `(file, qualname)`, a THIRD, + UNREGISTERED template landing inside an already-declared function is + indistinguishable from the two legitimate ones by key alone, and the old + code recorded only whether the key had been seen at all + (`seen_helper_keys: set`), not how many times. Concretely: adding a + second, shadowed-variable `f"bootstrap.{code}"` inside `_refusal` (a + comprehension `for code in (...)` shadows the parameter `_refusal` + already takes, so `ast.unparse` still reads the substituted expression as + plain `code`, and the declared literal/`kind` still match) passed every + existing assertion silently -- the only thing that caught it was + `EXPECTED_TEMPLATE_COUNT`'s own failure message, which says "bump the + count", not "this code is unregistered". Each `_RESOLVABLE_HELPERS` + entry therefore also declares `sites`: the exact number of + `_prefix_templates` matches expected at that key (`_run_forward`'s entry + reads `sites=2`, recording the "two, not one, not three" fact the old + code left implicit). [`_classify_and_resolve`] records every matched + lineno per key and calls [`_check_site_counts`], which fails, naming the + qualname, the declared vs. actual count, and every matched file:line, the + moment they diverge -- so the shadowed-comprehension shape above is now a + loud, specific failure ("_RESOLVABLE_HELPERS[...] declares sites=1 but + this run found 2") instead of a nudge to bump an unrelated scalar. * `_FORWARDER_SUFFIXES`, keyed by `(file, exact substituted expression)` -- the substituted expression is not a plain parameter (`refusal.code`, `venv_refusal.code`, `result.outcome`, or a `*tuple` unpack) but its value space was read from the real source and is small and closed (a dataclass field fed by a handful of constructors, or an outcome derived - from two module constants). - * `_ACKNOWLEDGED_CEILINGS`, keyed by `(file, lineno)` -- stated rather than + from two module constants). Carries the SAME `sites`-pinning discipline + the other two buckets do, for the SAME many-to-one reason, closing a + THIRD hole a reviewer found in this table specifically: the key is + `(file, expr)` -- no qualname, no call-site scope -- so a wholly + UNRELATED function whose own f-string happens to substitute an + identically-spelled expression collapses onto the same declared entry + and gets waved through by whatever suffix set that entry already + carries, without a single new call site ever being named. Measured: + adding `def _sneaky(refusal): return Issue(f"bootstrap.{refusal.code}", + "error", "smuggled")` to `bootstrap_cmd.py` and bumping + `EXPECTED_TEMPLATE_COUNT` 11 -> 12 gave "4 passed" -- every existing + assertion, silently. `sites` is the exact count of matches expected at + that key -- from [`_prefix_templates`]'s f-string scan for the three + plain-expression entries, or from [`_resolve_helper`]'s own + Starred-argument scan for the two `*tuple` entries -- asserted by the + SAME [`_check_site_counts`] the other two buckets now share. + * `_ACKNOWLEDGED_CEILINGS`, keyed by `(file, enclosing qualname)` -- the + same stable identity `_RESOLVABLE_HELPERS` keys on, for the same reason + (see its bullet above), mapping to `dict(reason=..., sites=...)` -- the + same `sites`-pinning discipline `_RESOLVABLE_HELPERS` carries, for the + same reason: an acknowledged ceiling is exactly as many-to-one a key as a + resolved helper, so a SECOND, unregistered template landing at an + already-acknowledged qualname deserves the same loud count mismatch, not + a silent "well, it's acknowledged" pass. `reason` is stated rather than silently skipped, the same honesty the Rust gate's own "KNOWN CEILING" paragraph practises. Held EMPTY today: `doctor_cmd.py`'s `check.code or f"doctor.{check.name}"` ceiling this bucket used to carry @@ -130,6 +192,66 @@ to fire. Confirmed by hand while writing this file: with the fabricated code below removed from the injected set the assertion goes red; restored, green -- see that test's own body for the same check run programmatically. + +ACKNOWLEDGED CEILING -- a limit of static resolution, documented rather than +chased closed (a reviewer's second finding, tan-cli#224 review). Every bucket +above resolves a template's substituted expression by reading its TEXT +(`ast.unparse`) and matching that text against the enclosing callable's own +parameter name or a declared forward -- it does not, and structurally cannot +without reimplementing Python's own name resolution, check that the text it +read is actually BOUND the way the enclosing signature implies. A construct +that locally REBINDS the substituted name defeats that match while leaving +every surface signal this file checks unchanged. Measured, not hypothetical: +`_refusal`'s legitimate site is + + [Issue(f"bootstrap.{code}", "error", " ".join(lines))] + +where `code` is `_refusal`'s own parameter. Replacing it with + + [Issue(f"bootstrap.{code}", "error", " ".join(lines)) for code in ("sneaky-unregistered",)] + +keeps `sites=1` (still exactly one `f"bootstrap.{code}"` AST node), +keeps `kind`/literal/`expr` all matching `_RESOLVABLE_HELPERS[("tan/commands +/bootstrap_cmd.py", "_refusal")]` (`ast.unparse` reads the substituted name +as the bare identifier `code` either way -- it has no notion of WHICH `code` +a comprehension's own scope binds, only that the token spells the same), +keeps `EXPECTED_TEMPLATE_COUNT` at 11 -- and the gate stays green while +`bootstrap.sneaky-unregistered` is emitted at runtime, unregistered. + +WHAT THIS GATE DOES CATCH: every literal code, every `_FULL_CODE_CALLABLES` +site, and every prefix/family template whose substituted expression is +textually the identifier the matching declared spec says it is -- which is +every real site in this tree today (measured while writing this file: zero +unclassified, zero unresolved). WHAT IT STRUCTURALLY CANNOT: tell "this +occurrence of `code` is the enclosing function's OWN parameter" apart from +"this occurrence of `code` is a comprehension/`with`/`except`/nested-`def` +target that merely happens to share that spelling" -- that is a lexical- +scoping question, not an AST-shape one, and answering it for real means +building the same name-resolution pass CPython's own compiler does. Do NOT +add a heuristic that tries to detect local rebinding of a substituted name +-- shadowing a name is common, legitimate Python (this file's own `_scan`/ +`_walk` helpers do it), so a detector for it would either miss a subtler +shadow just as easily or flag ordinary, harmless code as suspect; either +way it is an arms race against a static-analysis limit, not a fix for one. + +THE ACTUAL BACKSTOP: this gate is a completeness REMINDER at review time, +not the last line of defence against an unregistered code reaching a +customer. The authority for what a consumer may rely on is +`contract/issue-codes.json` itself, and the release workflow's own "Bundle +the envelope contract" step (`.github/workflows/release.yml`) builds the +published `envelope-contract.json` directly FROM that registry file -- by +reading it, never by re-scanning `python/tan/` source -- so a code that +escapes this static scan is exactly as absent from the published contract as +a code nobody ever tried to register in the first place; this scan's ceiling +does not create a NEW way for that to happen, it just fails to add coverage +for one narrow shape of it. And on the wire, `alp-sdk-vscode`'s own `===` +match against a `frozen` code FAILS OPEN on anything it does not recognise +(`test_frozen_issue_codes.py`'s own docstring: "an unrecognised code is +indistinguishable from 'no problem' on the consumer side") -- so the +practical cost of this ceiling is a real problem going unsurfaced to a user +who hit it, not a crash, and the actual defence against that is a human +registering every new code in the one place (`contract/issue-codes.json`) +this gate, `test_frozen_issue_codes.py`, and the release step all read from. """ from __future__ import annotations @@ -394,14 +516,27 @@ def _record(lineno: int, site: str, value: ast.expr | None) -> None: return found, unresolved -def _prefix_templates(path: pathlib.Path) -> list[tuple[int, str, str, str]]: +def _prefix_templates(path: pathlib.Path) -> list[tuple[int, str, str, str, str]]: """Every f-string used AT A CODE POSITION in `path` -- `Issue(...)`'s first argument, or a `code=` keyword's value, the SAME two positions [`_literal_codes_in_file`] inspects -- shaped EXACTLY `[one fixed literal segment ending/starting with `.`, ONE substitution]`, in either order -- e.g. `f"bootstrap.{code}"` (`kind="prefix"`) or `f"{subcommand}.failed"` (`kind="family"`). Returns `(lineno, literal segment, unparsed - substituted expression, kind)`. + substituted expression, kind, enclosing qualname)`. + + The enclosing qualname (`Log.take_issues`, `_refusal`, `validate.fail`) + is the dotted name of the innermost function/method/class the f-string + sits inside, built while walking (the same idea Python's own + `__qualname__` encodes, minus the `` marker CPython inserts for a + nested function -- unneeded here, since nothing downstream reconstructs a + real `__qualname__`, it only looks a site up BY this identity). This is + what [`_RESOLVABLE_HELPERS`]/[`_ACKNOWLEDGED_CEILINGS`] key on INSTEAD of + `lineno`: `lineno` is still returned (and still belongs in every error + message, so a human can jump straight to the site), but it is not part of + any declared, hand-maintained key any more -- see those tables' own + comments for the concrete break (tan-cli#224, dev's fc88ca1) that made + keying on it a defect rather than a convenience. Scoping to code positions (rather than "any f-string in the file") is load-bearing, not cosmetic: this tree has MANY unrelated f-strings @@ -420,11 +555,16 @@ def _prefix_templates(path: pathlib.Path) -> list[tuple[int, str, str, str]]: `check.code or f"doctor.{check.name}"`, a `BoolOp` -- is still found. """ tree = _parse(path) - out: list[tuple[int, str, str, str]] = [] + out: list[tuple[int, str, str, str, str]] = [] + stack: list[str] = [] + + def _qualname() -> str: + return ".".join(stack) if stack else "" def _scan(value: ast.expr | None) -> None: if value is None: return + qualname = _qualname() for node in ast.walk(value): if not isinstance(node, ast.JoinedStr) or len(node.values) != 2: continue @@ -435,7 +575,7 @@ def _scan(value: ast.expr | None) -> None: and isinstance(tail.value, str) and _is_family_suffix(tail.value) ): - out.append((node.lineno, tail.value, ast.unparse(head.value), "family")) + out.append((node.lineno, tail.value, ast.unparse(head.value), "family", qualname)) continue if ( isinstance(head, ast.Constant) @@ -443,14 +583,29 @@ def _scan(value: ast.expr | None) -> None: and _is_family_prefix(head.value) and isinstance(tail, ast.FormattedValue) ): - out.append((node.lineno, head.value, ast.unparse(tail.value), "prefix")) - - for node in ast.walk(tree): + out.append((node.lineno, head.value, ast.unparse(tail.value), "prefix", qualname)) + + def _walk(node: ast.AST) -> None: + # Tracks the enclosing def/class stack (for the qualname `_scan` + # reads) while still visiting EVERY node in the tree, the same + # completeness the old flat `ast.walk(tree)` had -- a nested + # function/class pushes its name, recurses into its own body, then + # pops, so a template two scopes deep still gets the full dotted + # name. + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + stack.append(node.name) + for child in ast.iter_child_nodes(node): + _walk(child) + stack.pop() + return if isinstance(node, ast.keyword) and node.arg == "code": _scan(node.value) - continue - if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "Issue" and node.args: + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "Issue" and node.args: _scan(node.args[0]) + for child in ast.iter_child_nodes(node): + _walk(child) + + _walk(tree) return out @@ -466,26 +621,91 @@ def _scan(value: ast.expr | None) -> None: #: `expected_sites` are pinned rather than floored: a template silently #: disappearing (a rename that stops covering an emit) is exactly as real a #: defect as a new one silently appearing uncovered, and only an EXACT count -#: notices the first case. +#: notices the first case. Deliberately kept as a scalar count, not replaced: +#: it is ALREADY immune to the line-shift defect this file's re-keying fixes +#: (tan-cli#224) -- it counts template OCCURRENCES across the tree, never a +#: line number, so an unrelated edit that merely moves existing sites cannot +#: change it. It still needs a hand bump on a genuine content change (a +#: template added or removed), which is the correct, narrow cost a drift +#: detector for "did the template COUNT change" should have -- the defect +#: fixed here was the SEPARATE `_RESOLVABLE_HELPERS`/`_ACKNOWLEDGED_CEILINGS` +#: keys pinning WHERE each one lives, not this total. +#: +#: KEPT alongside the per-key `sites` field ALL THREE declared tables now +#: carry (`_RESOLVABLE_HELPERS`/`_ACKNOWLEDGED_CEILINGS` from the first +#: review round, `_FORWARDER_SUFFIXES` from the second, tan-cli#224 review +#: remediation), deliberately not replaced by either: the two are NOT +#: redundant, because they cover different ground. `sites` only exists at +#: keys someone has DECLARED in one of the three tables -- of today's 11 +#: templates, all 11 now sit behind a `sites` pin, the last 3 +#: (`bootstrap_cmd.py`'s `refusal.code` forward, `doctor_cmd.py`'s +#: `venv_refusal.code` forward, `validate_cmd.py`'s `result.outcome` forward) +#: via `_FORWARDER_SUFFIXES`'s OWN `sites` field once a reviewer showed the +#: identical many-to-one collapse reaches a `(file, expr)` key exactly as +#: easily as a `(file, qualname)` one -- but a BRAND NEW expression or +#: qualname nobody has declared ANYWHERE still only shows up as a total +#: mismatch here, never as a per-key one (there is no key yet for it to +#: collapse onto). Conversely, `sites` catches something this scalar cannot +#: localize on its own: which SPECIFIC key absorbed an extra template, by +#: name, with every offending file:line -- this total only says "11 became +#: 12" and, on its own, invites exactly the "bump the number and move on" +#: response the reviewer's exploit relied on. One coarse, whole-tree tripwire +#: plus precise per-key tripwires is not the same overlap as two detectors +#: both hand-bumped for the SAME fact; dropping either narrows real coverage. EXPECTED_TEMPLATE_COUNT = 11 -#: `(file, lineno-of-the-f-string)` -> how to recover the missing half, for -#: every template resolvable by scanning one declared callable's call sites. -#: Keyed on the f-string's own line (not `(file, prefix, expr)`) because -#: `bootstrap_cmd.py` has TWO distinct such templates that both happen to -#: substitute a parameter named `code` -- (file, prefix, expr) alone cannot -#: tell them apart. `kind` (default `"prefix"`) picks which half is fixed: -#: `"prefix"` -- `prefix` is the fixed literal, `expr`'s call-site argument is -#: the SUFFIX (`f"bootstrap.{code}"`, `bootstrap.` + scanned code); `"family"` -#: -- `suffix` is the fixed literal, `expr`'s call-site argument is the FAMILY +#: `(file, enclosing qualname)` -> how to recover the missing half, for every +#: template resolvable by scanning one declared callable's call sites. +#: `enclosing qualname` is the dotted name of the function/method the +#: f-string ITSELF sits inside (`Log.take_issues`, `_refusal`, +#: `validate.fail`), tracked by [`_prefix_templates`] while it walks -- +#: DELIBERATELY NOT the f-string's line number, which an earlier version of +#: this table used and which broke for real (tan-cli#224): dev's fc88ca1 +#: shifted `_refusal`'s own template from :1522 to :1532 by adding lines +#: earlier in the same file, changing nothing about `_refusal` itself, and +#: reddened this gate on an unrelated, already-merged PR. A qualname is +#: immune to that -- it only changes if the SITE itself is renamed, moved to +#: a different function, or rewritten, all of which genuinely warrant +#: updating this table -- and it still disambiguates every case a bare +#: `(file, prefix, expr)` key could not: `bootstrap_cmd.py` has TWO distinct +#: templates that both substitute a parameter literally named `code`, but +#: one sits in `Log.take_issues` and the other in `_refusal`. `kind` +#: (default `"prefix"`) picks which half is fixed: `"prefix"` -- `prefix` is +#: the fixed literal, `expr`'s call-site argument is the SUFFIX +#: (`f"bootstrap.{code}"`, `bootstrap.` + scanned code); `"family"` -- +#: `suffix` is the fixed literal, `expr`'s call-site argument is the FAMILY #: (`f"{subcommand}.failed"`, scanned subcommand + `.failed`). Either way the #: named callable's call sites are scanned the same way (`name`/`attr`, #: `arg_index`/`arg_keyword`) -- a constructor whose call sites carry the #: missing half works exactly like a helper whose OWN parameter does #: (`doctor_cmd.py`'s `Check(name=...)` below is a constructor, not the #: f-string's enclosing function; `_resolve_helper` does not care which). -_RESOLVABLE_HELPERS: dict[tuple[str, int], dict] = { - ("tan/commands/bootstrap_cmd.py", 271): dict( +#: Two templates that share one qualname (`west_forward_cmd.py`'s +#: `_run_forward`, whose success and `OSError` arms both build the identical +#: `f"{subcommand}.failed"`) collapse to ONE entry below, not two -- they are +#: the same resolvable site scanned once, never two coincidentally-identical +#: declarations to keep in sync by hand. +#: Every entry's `sites` field is the exact number of `_prefix_templates` +#: MATCHES (f-string occurrences) expected at that `(file, qualname)` key -- +#: a SEPARATE axis from `expected_calls` (the number of calls to the scanned +#: helper/constructor itself, e.g. `Log.warn(...)` call sites). This is the +#: closing of the hole a reviewer found in the qualname re-keying (tan-cli +#: #224): because the key is `(file, qualname)`, not `(file, lineno)`, a +#: SECOND, unregistered template appearing inside an already-declared +#: function/method collapses onto the SAME key -- the old code only recorded +#: key MEMBERSHIP (`seen_helper_keys: set`), which a second occurrence at an +#: already-seen key does not change, so it passed silently as long as its +#: `kind`/literal/`expr` happened to match the declared spec (exactly the +#: shape a shadowed comprehension variable produces). `sites` makes the COUNT +#: itself a declared, asserted fact -- `_classify_and_resolve` records every +#: lineno matched per key and hands it to [`_check_site_counts`], which fails, +#: naming the qualname, both counts, and every matched file:line, when the +#: real count differs from `sites`. Bump +#: `sites` ONLY after confirming each newly-listed line is a legitimate, +#: already-registered emit -- the same discipline `expected_calls`'s own +#: docstring insists on for `_resolve_helper`'s call-site count. +_RESOLVABLE_HELPERS: dict[tuple[str, str], dict] = { + ("tan/commands/bootstrap_cmd.py", "Log.take_issues"): dict( # `Log.warn(self, code, message)`, drained by `take_issues` into # this exact f-string -- every call site is `.warn(...)`. prefix="bootstrap.", @@ -493,36 +713,41 @@ def _scan(value: ast.expr | None) -> None: attr="warn", arg_index=0, expected_calls=16, + sites=1, ), - ("tan/commands/bootstrap_cmd.py", 1522): dict( + ("tan/commands/bootstrap_cmd.py", "_refusal"): dict( prefix="bootstrap.", expr="code", name="_refusal", arg_index=1, expected_calls=8, + sites=1, ), - ("tan/commands/debug_config_cmd.py", 749): dict( + ("tan/commands/debug_config_cmd.py", "_failure"): dict( prefix="debug-config.", expr="code", name="_failure", arg_keyword="code", expected_calls=2, + sites=1, ), - ("tan/commands/sdk_cmd.py", 779): dict( + ("tan/commands/sdk_cmd.py", "_fail"): dict( prefix="sdk.", expr="code", name="_fail", arg_keyword="code", expected_calls=5, + sites=1, ), - ("tan/commands/validate_cmd.py", 485): dict( + ("tan/commands/validate_cmd.py", "validate.fail"): dict( prefix="validate.", expr="code", name="fail", arg_index=0, expected_calls=5, + sites=1, ), - ("tan/commands/doctor_cmd.py", 1706): dict( + ("tan/commands/doctor_cmd.py", "checks_to_issues"): dict( # `check.code or f"doctor.{check.name}"` in `checks_to_issues()` -- # the ceiling this bucket used to acknowledge instead of resolving # (tan-cli#224 review): MEASURED at 48 `Check(...)` constructions, @@ -534,72 +759,110 @@ def _scan(value: ast.expr | None) -> None: name="Check", arg_index=0, expected_calls=48, + sites=1, ), - ("tan/commands/west_forward_cmd.py", 124): dict( + ("tan/commands/west_forward_cmd.py", "_run_forward"): dict( # `Issue(f"{subcommand}.failed", ...)` -- the MIRRORED shape # (tan-cli#224 review): `subcommand` is `_run_forward`'s own # parameter, closed to the three literal strings its three Typer - # callers (`migrate`/`lock`/`quality`) pass. - kind="family", - suffix=".failed", - expr="subcommand", - name="_run_forward", - arg_index=0, - expected_calls=3, - ), - ("tan/commands/west_forward_cmd.py", 133): dict( + # callers (`migrate`/`lock`/`quality`) pass. TWO templates in this + # one function (the success arm and the `OSError` arm) share this + # exact spec and collapse to one declared entry here -- `sites=2` + # is what now RECORDS that fact instead of leaving it to a comment: + # before this field existed, nothing distinguished "one function, + # two known templates" from "one function, one known template plus + # a silently-collapsed unregistered one" -- see this table's own + # comment above. kind="family", suffix=".failed", expr="subcommand", name="_run_forward", arg_index=0, expected_calls=3, + sites=2, ), } -#: `(file, exact substituted expression text)` -> the closed, source-verified -#: set of suffixes a FORWARDED expression can carry. Every entry here was -#: read from the origin, not guessed -- see the module docstring's bucket -#: description. `warn(*skew)`/`warn(*ceiling)` are keyed by the call shape -#: rather than the bare unparsed name, because a `Starred` argument is not a -#: template substitution at all -- it is resolved per CALL SITE inside -#: `_resolve_helper`, not per f-string. -_FORWARDER_SUFFIXES: dict[tuple[str, str], frozenset[str]] = { - # `Issue(f"bootstrap.{refusal.code}", ...)` at bootstrap_cmd.py:2072 +#: `(file, exact substituted expression text)` -> `dict(suffixes=..., sites=...)` +#: -- `suffixes` is the closed, source-verified set of suffixes a FORWARDED +#: expression can carry, read from the origin, not guessed (see the module +#: docstring's bucket description). `warn(*skew)`/`warn(*ceiling)` are keyed +#: by the call shape rather than the bare unparsed name, because a `Starred` +#: argument is not a template substitution at all -- it is resolved per CALL +#: SITE inside `_resolve_helper`, not per f-string. +#: +#: `sites` carries the SAME discipline `_RESOLVABLE_HELPERS`/ +#: `_ACKNOWLEDGED_CEILINGS` do, for a REVIEWER-found reason specific to this +#: table: the key is `(file, expr)` -- no qualname, no enclosing-scope +#: information at all -- so a wholly UNRELATED function elsewhere in the same +#: file whose own f-string happens to substitute an identically-spelled +#: expression collapses onto the same entry and is silently resolved by +#: whatever suffix set that entry already declares. Measured: adding +#: `def _sneaky(refusal): return Issue(f"bootstrap.{refusal.code}", "error", +#: "smuggled")` to `bootstrap_cmd.py` and bumping `EXPECTED_TEMPLATE_COUNT` +#: 11 -> 12 gave "4 passed" before this field existed. For the three plain- +#: expression entries, `sites` is the exact count of [`_prefix_templates`] +#: matches at that `(file, expr)` key (asserted by [`_check_site_counts`], +#: the same function the other two tables share); for the two `*tuple` +#: entries, it is the exact count of Starred-argument call sites +#: [`_resolve_helper`] itself matches to that key -- a DIFFERENT scan +#: (`_resolve_helper`'s own `expected_calls` loop over calls to the ONE +#: helper each entry's `warn(*...)` shape names), fed into the SAME +#: `_check_site_counts` check by `_classify_and_resolve` merging both scans' +#: hits before calling it. +_FORWARDER_SUFFIXES: dict[tuple[str, str], dict] = { + # `Issue(f"bootstrap.{refusal.code}", ...)` in bootstrap_cmd.py -- no line + # number on purpose: this whole table was re-keyed off line numbers because + # they rot (tan-cli#224), and a comment that pins one rots the same way. # forwards `check_prerequisites()`'s `PrereqFailure.code` # (`tan/core/bootstrap.py`), which is exactly one of these four literals # depending on which refusal branch it returned. - ("tan/commands/bootstrap_cmd.py", "refusal.code"): frozenset( - {"prerequisites-missing", "python-not-runnable", "python-too-old", "venv-unusable"} + ("tan/commands/bootstrap_cmd.py", "refusal.code"): dict( + suffixes=frozenset({"prerequisites-missing", "python-not-runnable", "python-too-old", "venv-unusable"}), + sites=1, ), # `code=f"bootstrap.{venv_refusal.code}"` at doctor_cmd.py:662 forwards # the SAME `PrereqFailure`, but `venv_refusal` there is only ever set # from `posix_venv_unusable()` (doctor_cmd.py:2342) -- a strictly # narrower value space than the bootstrap_cmd.py forward above. - ("tan/commands/doctor_cmd.py", "venv_refusal.code"): frozenset({"venv-unusable"}), + ("tan/commands/doctor_cmd.py", "venv_refusal.code"): dict(suffixes=frozenset({"venv-unusable"}), sites=1), # `Issue(f"validate.{result.outcome}", ...)` at validate_cmd.py:546 only # ever fires inside `for message in result.messages`, and # `outcome = OUTCOME_CLEAN if not messages else OUTCOME_SCHEMA_VIOLATION` # (validate_cmd.py:272) means a non-empty `messages` implies # `outcome == OUTCOME_SCHEMA_VIOLATION == "schema-violation"` always. - ("tan/commands/validate_cmd.py", "result.outcome"): frozenset({"schema-violation"}), - # `log.warn(*skew)` / `log.warn(*ceiling)` at bootstrap_cmd.py:2061/:2326 + ("tan/commands/validate_cmd.py", "result.outcome"): dict(suffixes=frozenset({"schema-violation"}), sites=1), + # `log.warn(*skew)` / `log.warn(*ceiling)` in bootstrap_cmd.py (no line + # numbers -- see the note on the entry above) # unpack the `(suffix, message)` pairs `python_floor_skew_warning()` and # `python_ceiling_warning()` (`tan/core/bootstrap.py`) return. - ("tan/commands/bootstrap_cmd.py", "warn(*skew)"): frozenset({"python-floor-skew"}), - ("tan/commands/bootstrap_cmd.py", "warn(*ceiling)"): frozenset({"python-newer-than-verified"}), + ("tan/commands/bootstrap_cmd.py", "warn(*skew)"): dict(suffixes=frozenset({"python-floor-skew"}), sites=1), + ("tan/commands/bootstrap_cmd.py", "warn(*ceiling)"): dict( + suffixes=frozenset({"python-newer-than-verified"}), sites=1 + ), } -#: `(file, lineno)` -> why this template is deliberately NOT resolved here, -#: stated rather than silently absent (matching `contract.rs`'s own "KNOWN -#: CEILING" paragraph). See the module docstring's third bucket. Held EMPTY -#: today: `doctor_cmd.py`'s `check.code or f"doctor.{check.name}"` was the -#: sole entry until tan-cli#224's own review measured its actual cost (48 -#: `Check(...)` call sites, ALL literal) and found it resolvable, not a real -#: ceiling -- it moved to `_RESOLVABLE_HELPERS` above. The bucket stays -#: declared, not deleted, so a genuinely out-of-scope FUTURE template has -#: somewhere honest to go rather than forcing a false resolution. -_ACKNOWLEDGED_CEILINGS: dict[tuple[str, int], str] = {} +#: `(file, enclosing qualname)` -- the same stable identity +#: `_RESOLVABLE_HELPERS` keys on, for the same reason (see its own comment +#: above: a line number moves on any unrelated edit, tan-cli#224) -- -> a +#: `dict(reason=..., sites=...)`, the same `sites`-pinning discipline +#: `_RESOLVABLE_HELPERS` carries (see its own leading comment): `reason` is +#: why this template is deliberately NOT resolved, stated rather than +#: silently absent (matching `contract.rs`'s own "KNOWN CEILING" paragraph); +#: `sites` is the exact number of `_prefix_templates` matches acknowledged at +#: that key, so a SECOND, unregistered template arriving at an already- +#: acknowledged qualname is a named count mismatch, not a silent collapse -- +#: the same hole closed in `_RESOLVABLE_HELPERS`, applied here too since nothing +#: about "this key is an acknowledged ceiling instead of a resolved helper" +#: makes it immune to the same many-to-one key risk. +#: See the module docstring's third bucket. Held EMPTY today: `doctor_cmd +#: .py`'s `check.code or f"doctor.{check.name}"` was the sole entry until +#: tan-cli#224's own review measured its actual cost (48 `Check(...)` call +#: sites, ALL literal) and found it resolvable, not a real ceiling -- it +#: moved to `_RESOLVABLE_HELPERS` above. The bucket stays declared, not +#: deleted, so a genuinely out-of-scope FUTURE template has somewhere honest +#: to go rather than forcing a false resolution. +_ACKNOWLEDGED_CEILINGS: dict[tuple[str, str], dict] = {} def _calls_matching(tree: ast.Module, *, attr: str | None = None, name: str | None = None) -> list[ast.Call]: @@ -628,13 +891,13 @@ def _resolve_helper( arg_index: int | None = None, arg_keyword: str | None = None, expected_calls: int, -) -> tuple[set[str], list[str]]: +) -> tuple[set[str], list[str], dict[tuple[str, str], list[int]]]: """Scan every call to the declared helper in `path`, read the code argument at `arg_index` (positional) or `arg_keyword`, and return - `(reconstructed codes, unresolved-descriptions)`. `kind="prefix"` - (default) reconstructs `prefix + `; `kind="family"` - reconstructs ` + suffix` (`west_forward_cmd.py`'s - mirrored shape). + `(reconstructed codes, unresolved-descriptions, forwarder-hit linenos)`. + `kind="prefix"` (default) reconstructs `prefix + `; + `kind="family"` reconstructs ` + suffix` + (`west_forward_cmd.py`'s mirrored shape). `expected_calls` is asserted EXACTLY, mirroring `PREFIXING_SITES`'s own pinned per-file counts (`contract.rs:663-682`) for the identical reason: @@ -644,7 +907,12 @@ def _resolve_helper( A `Starred` argument (`log.warn(*skew)`, unpacking a 2-tuple rather than passing the code positionally) is looked up in `_FORWARDER_SUFFIXES` by `f"{opener}(*{expr})"`; anything else non-literal is reported UNRESOLVED - -- never a silent skip. + -- never a silent skip. Every matched Starred call's lineno is also + recorded against its `_FORWARDER_SUFFIXES` key in the returned dict, so + `_classify_and_resolve` can feed it into the SAME `_check_site_counts` + that pins `_RESOLVABLE_HELPERS`/`_ACKNOWLEDGED_CEILINGS` -- this is the + ONLY place a Starred forward's `sites` count can be measured from, since + [`_prefix_templates`] never sees a Starred call (it is not an f-string). """ opener = attr or name rel = _rel(path) @@ -658,6 +926,7 @@ def _resolve_helper( ) parts: set[str] = set() unresolved: list[str] = [] + forwarder_hits: dict[tuple[str, str], list[int]] = {} for call in calls: arg: ast.expr | None if arg_keyword is not None: @@ -680,7 +949,8 @@ def _resolve_helper( f"_FORWARDER_SUFFIXES with the known suffix set, read from source." ) else: - parts |= declared + parts |= declared["suffixes"] + forwarder_hits.setdefault(key, []).append(call.lineno) continue got = ast.unparse(arg) if arg is not None else "no matching argument" unresolved.append( @@ -691,13 +961,73 @@ def _resolve_helper( ) if kind == "prefix": assert prefix is not None - return {prefix + s for s in parts}, unresolved + return {prefix + s for s in parts}, unresolved, forwarder_hits assert suffix is not None - return {s + suffix for s in parts}, unresolved + return {s + suffix for s in parts}, unresolved, forwarder_hits + + +def _check_site_counts( + declared: dict[tuple[str, str], dict], seen: dict[tuple[str, str], list[int]], bucket: str +) -> list[str]: + """The count check the qualname/expr re-keying gap needs, shared by all + THREE many-to-one declared tables (`_RESOLVABLE_HELPERS`, + `_ACKNOWLEDGED_CEILINGS`, `_FORWARDER_SUFFIXES`): `declared` maps a + `(file, identity)` key -- `identity` is an enclosing qualname for the + first two tables, a substituted expression for the third -- to a spec + carrying a `sites` int, the exact number of matches that key is declared + to cover. `seen` is what THIS RUN actually found there, keyed the same + way (from [`_prefix_templates`] for the qualname-keyed tables and the + plain-expression `_FORWARDER_SUFFIXES` entries; from + [`_resolve_helper`]'s own Starred-argument scan for the `*tuple` + `_FORWARDER_SUFFIXES` entries -- see that function's docstring). Any + mismatch, in EITHER direction, is returned as a message naming the key, + both counts, and every matched file:line -- never silently absorbed the + way a bare membership check (`key in declared`) would. + + Deliberately extracted to a MODULE-LEVEL function, not left as a closure + inside `_classify_and_resolve` (tan-cli#224 review, MAJOR finding): a + closure captures its enclosing scope and cannot be called with synthetic + input by a test, so the only thing that had ever watched this exact + assertion fire was a hand-edit of the production tree -- which does not + survive into CI, precisely the tan-cli#275 lesson this file cites about + itself elsewhere. Returns a list of messages rather than raising or + mutating a list captured from the caller, so a test can call this + directly and assert on the return value -- see + `test_check_site_counts_flags_a_declared_vs_actual_mismatch`, which does + exactly that for both the resolved-helper shape and the (empty-in- + production, doubly unproven without this) acknowledged-ceiling shape. + """ + messages: list[str] = [] + for key, spec in declared.items(): + rel, identity = key + expected = spec["sites"] + lines = sorted(seen.get(key, [])) + if len(lines) == expected: + continue + where = ", ".join(f"{rel}:{ln}" for ln in lines) or "none" + if len(lines) > expected: + what_to_do = ( + "a NEW, unregistered site landed at this same declared key instead of " + "being caught -- read each line above, confirm which is genuinely new, " + "register its code (add it to contract/issue-codes.json, the same as any " + "other emit site), and only then bump `sites` to match." + ) + else: + what_to_do = ( + "a declared site disappeared -- it was renamed, moved to a different " + "function, or removed; update or delete this entry (and " + "EXPECTED_TEMPLATE_COUNT if the total template count changed, not just " + "this key's share of it)." + ) + messages.append( + f"{bucket}[{key!r}] (at {identity}) declares sites={expected} but this run " + f"found {len(lines)} at {rel}: {where} -- {what_to_do}" + ) + return messages def _classify_and_resolve( - templates: dict[str, list[tuple[int, str, str, str]]], + templates: dict[str, list[tuple[int, str, str, str, str]]], ) -> tuple[set[str], list[str], list[str]]: """Walk every discovered template, classify it into one of the three declared buckets, and resolve the ones that are classified. Returns @@ -706,51 +1036,76 @@ def _classify_and_resolve( codes: set[str] = set() unresolved: list[str] = [] unclassified: list[str] = [] - seen_helper_keys: set[tuple[str, int]] = set() + # `(file, qualname)` -> every `_prefix_templates` lineno matched at that + # key, for _RESOLVABLE_HELPERS and _ACKNOWLEDGED_CEILINGS respectively. + # DELIBERATELY not a `set` of keys seen (the old `seen_helper_keys`): the + # key is `(file, qualname)`, a many-to-one mapping (every template inside + # one function shares it), so recording only MEMBERSHIP cannot notice a + # SECOND, unregistered template landing on an already-declared key -- the + # exact hole a reviewer found in the qualname re-keying (tan-cli#224): a + # shadowed comprehension variable inside `_refusal` produces a second + # `f"bootstrap.{code}"` whose kind/literal/expr all match the existing + # declaration, so it would pass the asserts below unnoticed. Recording + # every lineno lets the per-key count check further down compare the + # REAL count against each entry's declared `sites` and name the exact + # new (or missing) line. + seen_helper_lines: dict[tuple[str, str], list[int]] = {} + seen_ceiling_lines: dict[tuple[str, str], list[int]] = {} + # `(file, expr)` -> every lineno matched at that _FORWARDER_SUFFIXES key, + # from BOTH sources that can match one: a plain-expression template found + # right here in this loop, or (merged in below, after the + # `_RESOLVABLE_HELPERS` loop runs) a Starred call site `_resolve_helper` + # itself matches. Same many-to-one reasoning as the two dicts above, for + # the reviewer-found reason `_FORWARDER_SUFFIXES`'s own leading comment + # gives: this key has no qualname or call-site scope at all, so it is + # if anything an EASIER key for an unrelated site to collapse onto. + seen_forwarder_lines: dict[tuple[str, str], list[int]] = {} for rel, sites in templates.items(): - for lineno, literal, expr, kind in sites: - key = (rel, lineno) + for lineno, literal, expr, kind, qualname in sites: + # `key` is `(file, enclosing qualname)` -- NOT `(file, lineno)` -- + # so an unrelated edit that merely shifts this site's line cannot + # desync it from its declaration; see _RESOLVABLE_HELPERS' own + # comment (tan-cli#224). `lineno` still rides along on every + # message below, purely so a human can jump straight to the site. + key = (rel, qualname) if key in _ACKNOWLEDGED_CEILINGS: + seen_ceiling_lines.setdefault(key, []).append(lineno) continue if key in _RESOLVABLE_HELPERS: spec = _RESOLVABLE_HELPERS[key] spec_kind = spec.get("kind", "prefix") assert spec_kind == kind, ( - f"{rel}:{lineno} -- _RESOLVABLE_HELPERS declared kind={spec_kind!r} but " - f"the template now reads kind={kind!r}; the f-string changed shape -- " - f"update the declaration." + f"{rel}:{lineno} (in {qualname}) -- _RESOLVABLE_HELPERS declared " + f"kind={spec_kind!r} but the template now reads kind={kind!r}; the " + f"f-string changed shape -- update the declaration." ) declared_literal = spec["prefix"] if kind == "prefix" else spec["suffix"] assert declared_literal == literal and spec["expr"] == expr, ( - f"{rel}:{lineno} -- _RESOLVABLE_HELPERS declared literal={declared_literal!r} " - f"expr={spec['expr']!r} but the template now reads literal={literal!r} " - f"expr={expr!r}; the f-string changed shape -- update the declaration." + f"{rel}:{lineno} (in {qualname}) -- _RESOLVABLE_HELPERS declared " + f"literal={declared_literal!r} expr={spec['expr']!r} but the template now " + f"reads literal={literal!r} expr={expr!r}; the f-string changed shape -- " + f"update the declaration." ) - seen_helper_keys.add(key) + seen_helper_lines.setdefault(key, []).append(lineno) continue - fwd = _FORWARDER_SUFFIXES.get((rel, expr)) + fwd_key = (rel, expr) + fwd = _FORWARDER_SUFFIXES.get(fwd_key) if fwd is not None: - codes |= {literal + s for s in fwd} if kind == "prefix" else {s + literal for s in fwd} + seen_forwarder_lines.setdefault(fwd_key, []).append(lineno) + suffixes = fwd["suffixes"] + codes |= {literal + s for s in suffixes} if kind == "prefix" else {s + literal for s in suffixes} continue shape = f'f"{literal}{{{expr}}}"' if kind == "prefix" else f'f"{{{expr}}}{literal}"' unclassified.append( - f"{rel}:{lineno} -- new prefix template {shape} is not in " + f"{rel}:{lineno} (in {qualname}) -- new prefix template {shape} is not in " f"_RESOLVABLE_HELPERS, _FORWARDER_SUFFIXES or _ACKNOWLEDGED_CEILINGS. " f"Classify it in one of the three (see this file's module docstring)." ) - missing_helpers = sorted(set(_RESOLVABLE_HELPERS) - seen_helper_keys) - if missing_helpers: - unclassified.append( - f"_RESOLVABLE_HELPERS declares template(s) _prefix_templates no longer finds: " - f"{missing_helpers} -- the f-string moved, was rewritten, or was removed; " - f"update the declaration (and EXPECTED_TEMPLATE_COUNT)." - ) - for key, spec in _RESOLVABLE_HELPERS.items(): rel = key[0] - site_codes, site_unresolved = _resolve_helper( + site_codes, site_unresolved, site_forwarder_hits = _resolve_helper( TAN.parent / rel, kind=spec.get("kind", "prefix"), prefix=spec.get("prefix"), @@ -763,11 +1118,17 @@ def _classify_and_resolve( ) codes |= site_codes unresolved.extend(site_unresolved) + for fwd_key, lines in site_forwarder_hits.items(): + seen_forwarder_lines.setdefault(fwd_key, []).extend(lines) + + unclassified.extend(_check_site_counts(_RESOLVABLE_HELPERS, seen_helper_lines, "_RESOLVABLE_HELPERS")) + unclassified.extend(_check_site_counts(_ACKNOWLEDGED_CEILINGS, seen_ceiling_lines, "_ACKNOWLEDGED_CEILINGS")) + unclassified.extend(_check_site_counts(_FORWARDER_SUFFIXES, seen_forwarder_lines, "_FORWARDER_SUFFIXES")) return codes, unresolved, unclassified -def _all_prefix_templates() -> dict[str, list[tuple[int, str, str, str]]]: +def _all_prefix_templates() -> dict[str, list[tuple[int, str, str, str, str]]]: return {_rel(path): _prefix_templates(path) for path in sorted(TAN.rglob("*.py"))} @@ -803,9 +1164,13 @@ def test_every_prefix_template_is_classified(): templates = _all_prefix_templates() total = sum(len(sites) for sites in templates.values()) found_lines = "\n".join( - (f' {rel}:{lineno} f"{literal}{{{expr}}}"' if kind == "prefix" else f' {rel}:{lineno} f"{{{expr}}}{literal}"') + ( + f' {rel}:{lineno} (in {qualname}) f"{literal}{{{expr}}}"' + if kind == "prefix" + else f' {rel}:{lineno} (in {qualname}) f"{{{expr}}}{literal}"' + ) for rel, sites in templates.items() - for lineno, literal, expr, kind in sites + for lineno, literal, expr, kind, qualname in sites ) assert total == EXPECTED_TEMPLATE_COUNT, ( f'found {total} f-string prefix templates (`f"family.{{code}}"` shape) ' @@ -910,6 +1275,62 @@ def test_gate_rejects_a_deliberately_unregistered_code(): assert fabricated not in offenders_clean +def test_check_site_counts_flags_a_declared_vs_actual_mismatch(): + """tan-cli#224 review, MAJOR finding: `_check_site_counts` used to be a + closure inside `_classify_and_resolve`, so nothing could exercise it + directly -- the only thing that had ever watched its assertion fire was a + hand-edit of the production tree, which per tan-cli#275 (this file's own + standing lesson, applied to itself) does not count as proof an assertion + fires. Extracting it to a top-level function (see its own docstring) + makes it directly callable with synthetic input, the same self-test + discipline `test_gate_rejects_a_deliberately_unregistered_code` and + `test_prefix_template_scan_finds_a_fresh_synthetic_site` already apply to + their own targets. + + Exercises BOTH halves `_check_site_counts` is used for. `_RESOLVABLE_HELPERS`' + shape first, with a synthetic `declared` spec claiming `sites=1` against a + synthetic `seen` map recording 2 linenos at that key -- mirroring, at the + unit level, the exact shadowed-comprehension exploit `sites` was added to + catch (two templates silently sharing one declared key). Then + `_ACKNOWLEDGED_CEILINGS`'s shape, doubly unproven before this test: that + table is EMPTY in production today, so nothing had ever driven a real + value through this exact code path for that bucket at all, synthetic or + otherwise. + """ + key = ("tan/commands/selftest_cmd.py", "_selftest_helper") + declared = {key: dict(prefix="selftest.", expr="code", sites=1)} + seen_mismatched = {key: [10, 20]} + + messages = _check_site_counts(declared, seen_mismatched, "_RESOLVABLE_HELPERS") + assert len(messages) == 1, messages + msg = messages[0] + # Names the key... + assert "_RESOLVABLE_HELPERS[('tan/commands/selftest_cmd.py', '_selftest_helper')]" in msg, msg + # ...and BOTH counts: the declared expectation and the actual finding. + assert "declares sites=1" in msg, msg + assert "found 2" in msg, msg + assert "tan/commands/selftest_cmd.py:10" in msg and "tan/commands/selftest_cmd.py:20" in msg, msg + + # The _ACKNOWLEDGED_CEILINGS half -- same function, same synthetic + # mismatch, different bucket label -- doubly unproven beforehand since + # that table carries zero real entries to ever drive this path. + ceiling_messages = _check_site_counts(declared, seen_mismatched, "_ACKNOWLEDGED_CEILINGS") + assert len(ceiling_messages) == 1, ceiling_messages + assert ceiling_messages[0].startswith("_ACKNOWLEDGED_CEILINGS[("), ceiling_messages[0] + assert "declares sites=1" in ceiling_messages[0] and "found 2" in ceiling_messages[0], ceiling_messages[0] + + # And the negative: a `seen` count that matches `declared` produces no + # message at all -- proving this is sensitive to the mismatch, not + # unconditionally red. + assert _check_site_counts(declared, {key: [10]}, "_RESOLVABLE_HELPERS") == [] + + # And the mirrored direction: a declared key with NOTHING seen at all + # (the "a declared site disappeared" branch) is exactly as loud. + vanished = _check_site_counts(declared, {}, "_RESOLVABLE_HELPERS") + assert len(vanished) == 1, vanished + assert "declares sites=1" in vanished[0] and "found 0" in vanished[0], vanished[0] + + def test_prefix_template_scan_finds_a_fresh_synthetic_site(tmp_path: pathlib.Path): """A second self-test, at the AST-mechanics level rather than the registry-diff level: [`_prefix_templates`] and [`_literal_codes_in_file`] @@ -950,4 +1371,4 @@ def test_prefix_template_scan_finds_a_fresh_synthetic_site(tmp_path: pathlib.Pat assert unresolved == [], unresolved templates = _prefix_templates(synthetic) - assert templates == [(11, "selftestfamily.", "code", "prefix")], templates + assert templates == [(11, "selftestfamily.", "code", "prefix", "emit_two")], templates From bc662ef00ce48ea8326c256f6c7c45965caa41b1 Mon Sep 17 00:00:00 2001 From: Caner Alp <109098482+alpCaner@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:22:07 +0000 Subject: [PATCH 09/10] test(parity): measure the v0.6.0 command surface against the oracle instead of asserting it Milestone v0.6.0's goal is, verbatim, "Full command-surface parity with the v0.4.1 oracle: model, new-som, monitor, faultdecode, the introspection set, renode, and the seven entirely-unported verbs." Not one of those verbs appeared in this file's CASES, which covered --version, a bogus command, a bare invocation, debug-config, presets, clean and build --plan. The milestone's central claim had no instrument that could read it. Nine case-functions (17 node IDs) now cover every named verb, each established by RUNNING target/debug/tan rather than by reading crates/. Matching, as live parity cases: explain (overview, unknown-template, unknown-target), image, renode -- full envelopes byte-identical. Diverging, each as a documented known-divergence case that pins the divergence on BOTH sides so it fails if silently fixed or worsened: model, new-som, monitor, faultdecode, size, run, and all seven deferred verbs. Several are structural, not cosmetic. faultdecode's exit codes coincide at 2 while the envelopes differ entirely -- the port re-implemented it as ARMv8-M register arithmetic that reads no SDK, so it answers cli.parse-error where the oracle answers faultdecode.failed. new-som's Click command declares no --format option at all, so --format json is a usage error rather than an envelope. size's divergence is permanent: two runtimes rendering the same ENOENT, Rust's `(No such file or directory (os error 2))` against Python's `([Errno 2] No such file or directory: '')`. Two harness defects had to be closed for any of this to mean anything. The support-bundle case first encoded this host's tool inventory, gaining a third issue under a stripped PATH -- tan-cli#313 and tan-cli#324 reintroduced in the PR that fixed them. These cases spawn both binaries live every run, so unlike the frozen-replay cases they can pin PATH SYMMETRICALLY, and now do. empty_tool_inventory also seeds a real `which`: with a literally empty PATH the oracle's probe cannot resolve `which` itself and reports every tool missing because it could not look, which is an artefact, not an answer. It raises now rather than silently returning to that. rust_binary() preferred target/release over target/debug. On a tree with a stale release build that silently measured against the wrong oracle -- forced over the other four parity files it gave "108 failed, 8 passed", red but reading as port bugs, with eight cases passing against the wrong binary. It picks the most recently built profile now, refuses an mtime tie rather than breaking it silently, and a session-scoped fixture in the new conftest.py asserts the resolved binary's --version for every file under tests/parity. Wrongness fails loudly; absence stays a skip, which is each file's own gate to decide (missing_for_live's rule, tan-cli#272). Resolution happens in the fixture body, not at conftest import, so a raise cannot abort collection for the whole suite. --- python/tests/parity/conftest.py | 81 ++++ python/tests/parity/oracle.py | 165 ++++++-- python/tests/parity/test_oracle_parity.py | 446 +++++++++++++++++++++- 3 files changed, 662 insertions(+), 30 deletions(-) create mode 100644 python/tests/parity/conftest.py diff --git a/python/tests/parity/conftest.py b/python/tests/parity/conftest.py new file mode 100644 index 00000000..cc040e24 --- /dev/null +++ b/python/tests/parity/conftest.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Shared fixtures for every test module under ``tests/parity/``. + +Home of the one check that used to live inside ``test_oracle_parity.py`` +alone, opted into by a handful of cases there and inherited by nobody else: +proof that whatever ``target/{release,debug}/tan`` :func:`oracle.rust_binary` +resolved is actually the pinned oracle this whole suite is measured against, +not a stale profile silently standing in for it. + +``test_flash_oracle_parity.py``, ``test_image_size_oracle.py``, +``test_clean_parity.py`` and ``test_run_oracle_parity.py`` each bind +``RUST = rust_binary()`` at import time and gate their live cases on +``missing_for_live`` (an ABSENCE check only) with no version assertion of +their own. Under a live run (``TAN_PARITY_LIVE=1``) against a resolved-but- +wrong binary, every one of those files' failures reads as a wall of PORT +bugs, with nothing naming the oracle itself as the actual problem -- measured: +``TAN_PARITY_LIVE=1 TAN_RUST_BINARY=`` over just +``test_image_size_oracle.py`` + ``test_clean_parity.py`` produced "108 +failed, 8 passed, 9 skipped, 2 xfailed", eight of those passes measured +against the wrong oracle entirely. + +Fixing this per-file (each module opting into its own copy of the check, the +way ``test_oracle_parity.py``'s old ``pinned_oracle`` did) would need to land +in four places every time the pin changes. A session-scoped, autouse fixture +here is inherited by every module in this directory for free, including +``test_oracle_parity.py`` itself, which no longer defines its own copy. +""" +import subprocess + +import pytest + +from .oracle import PINNED_ORACLE_VERSION, rust_binary + +@pytest.fixture(scope="session", autouse=True) +def pinned_oracle() -> None: + """FAIL the session -- never skip -- when a resolved oracle binary is + present but does not report :data:`oracle.PINNED_ORACLE_VERSION`. + + Absence stays a skip: that is what each file's own ``missing_for_live``/ + ``_ORACLE_REQUIRED`` gate already decides, and this fixture defers to it + entirely by doing nothing when ``rust_binary()`` returns ``None``. Only WRONGNESS + fails here -- a resolved binary that answers the wrong ``--version`` -- + because a quiet skip in that case would hide exactly the gap this + harness exists to surface (``missing_for_live``'s own docstring makes the + identical argument for the absence case; tan-cli#272 is where that rule + was first written down, and it applies just as hard to wrongness as to + absence). + + Session-scoped so this runs the check ONCE per test process rather than + once per test case; the resolution cannot change mid-session, so + re-checking it per-test bought nothing but 17 extra ``--version`` + subprocesses a run. + + ``rust_binary()`` is called HERE, in the fixture body, and deliberately + NOT at this conftest's import. It can raise -- a ``TAN_RUST_BINARY`` that + is set but missing, or the mtime TIE between target/{release,debug} that + it now refuses to break silently -- and a raise at conftest import is an + ``ImportError while loading conftest``, which aborts the WHOLE pytest + session rather than this directory. Measured: with a bogus + ``TAN_RUST_BINARY``, ``pytest tests/parity tests/core`` collected zero + tests and exited rc=4, so the repo's own ``python -m pytest tests -q`` + gate would have reported nothing at all. Resolving in the fixture keeps + the blast radius on ``tests/parity``, which is what it is about. + """ + rust = rust_binary() + if rust is None: + return + proc = subprocess.run([rust, "--version"], capture_output=True, text=True, encoding="utf-8") + assert proc.returncode == 0, f"{rust} is not a working tan binary" + stdout = proc.stdout.strip() + assert stdout == PINNED_ORACLE_VERSION, ( + f"resolved oracle {rust!r} reports {stdout!r}, not the pinned " + f"{PINNED_ORACLE_VERSION!r} this whole parity suite is measured " + "against. oracle.rust_binary() picks the MOST RECENTLY BUILT of " + "target/{release,debug}/tan, so a resolved-but-wrong binary means " + "either an inverted or TIED mtime between the two profiles (a tie " + "raises inside rust_binary() itself -- see that function) or an " + "explicit TAN_RUST_BINARY naming the wrong one. Rebuild or remove " + "the stale profile, or set TAN_RUST_BINARY= explicitly." + ) diff --git a/python/tests/parity/oracle.py b/python/tests/parity/oracle.py index 3dfd7fec..cf6bf78b 100644 --- a/python/tests/parity/oracle.py +++ b/python/tests/parity/oracle.py @@ -22,9 +22,10 @@ ``VERSION`` Exit code plus the *shape* of the version line. The literals differ BY - DESIGN and permanently: the port declares 0.5.0-dev, the checked-out Rust - declares 0.4.1-dev (``python/tan/version.py`` records why the port must not - reuse the shipped number). What the extension actually contracts on is the + DESIGN and permanently: the port declares ``0.5.0-rc3`` + (``python/tan/version.py``, which records why the port must not reuse the + shipped number), the checked-out Rust declares ``0.4.1`` + (``Cargo.toml``). What the extension actually contracts on is the regex ``/^tan \\d+\\.\\d+\\.\\d+/`` (alp-sdk-vscode/src/alpCli/service.ts:107-121), so that is what is compared -- and a side that fails the regex outright is reported even when both @@ -36,7 +37,8 @@ compare that would enforce it. Prefix-anchoring here silently accepted ``"tan 0.5.0-dev\\nLEAKED EXTRA STDOUT LINE"`` and ``"tan 9.9.9 THIS IS NOT TAN AT ALL"`` as parity -- on the one case that - actually runs today. Rust prints exactly ``tan 0.4.1-dev``. + actually runs today. Rust prints exactly ``tan 0.4.1`` (see + :data:`PINNED_ORACLE_VERSION`, the one place that spelling is owned). ``PLAN`` Exit code, the envelope shell, and -- inside ``data`` -- a NARROWED view of @@ -78,6 +80,7 @@ import json import os import re +import shutil import subprocess import sys from dataclasses import dataclass @@ -202,16 +205,56 @@ class ParityResult: diffs: list[str] +#: The exact ``--version`` line the whole parity suite is measured against. +#: Not ``0.4.1-dev``: that spelling is this repo's OLDER PROSE for the RUST +#: oracle, from before ``Cargo.toml`` settled on ``version = "0.4.1"``, which +#: is why the binary prints no suffix today. It has never been the PORT's +#: string -- ``python/tan/version.py`` declares ``TAN_VERSION = "0.5.0-rc3"``. +#: The checked-out Rust binary's actual stdout, byte for byte, is +#: ``tan 0.4.1`` with no suffix (verified directly: ``tan --version | cat -A`` +#: -> ``tan 0.4.1$``). Shared here, not owned by any one test module, because +#: ``conftest.py``'s session-scoped ``pinned_oracle`` fixture checks against +#: it for every file under ``tests/parity/``, not just the module that used to +#: define it alone. +PINNED_ORACLE_VERSION = "tan 0.4.1" + + def rust_binary() -> str | None: - """``TAN_RUST_BINARY`` if set, else a build in the usual places. Returns - ``None`` only when NOBODY named an oracle and none was built, so the caller - can skip rather than invent a comparison. + """``TAN_RUST_BINARY`` if set, else the MOST RECENTLY BUILT of + ``target/{release,debug}/tan``. Returns ``None`` only when NOBODY named an + oracle and none was built, so the caller can skip rather than invent a + comparison. A set-but-missing ``TAN_RUST_BINARY`` RAISES instead. It must not fall back to some other binary -- an operator who named one should not silently get a different one -- but it must not skip either: a typo'd path in CI would then produce an all-skip, all-green run that certifies nothing, which is the exact failure mode this harness exists to prevent. + + When both profiles exist, the choice is by ``st_mtime``, not a fixed + release-over-debug preference (what this function used to do, + unconditionally). That preference used to silently pick a STALE binary: a + ``target/release/tan`` left over from a much earlier build (measured -- + ``tan 0.1.1``, weeks old) sitting next to a freshly rebuilt + ``target/debug/tan`` (``tan 0.4.1``) always won, because ``release`` was + tried first regardless of either file's age, and every unpinned caller of + this function silently measured against the wrong oracle with no signal + that anything was off. mtime is what an ordinary edit-build-test loop + actually implies: whichever profile was rebuilt LAST is the one a + developer (or CI job that just ran ``cargo build``) intends to test + against right now. A caller that legitimately wants one profile over the + other regardless of freshness should set ``TAN_RUST_BINARY`` explicitly + rather than rely on this default. + + A TIE (identical ``st_mtime`` on both profiles) is refused outright rather + than resolved silently -- silently is what this function used to do, and + it always broke to ``target/release``, which is exactly the stale-pick bug + the mtime rule above replaced. A CI cache restore, ``cp -p``, rsync, or an + artifact download can equalise (or invert) mtimes on ``target/`` with no + rebuild involved, so a tie is a real, reachable way to reinstate that bug + for every caller that does not separately assert the resolved version (see + ``conftest.py``'s ``pinned_oracle``, which is that safety net -- this raise + is the thing it nets). """ override = os.environ.get("TAN_RUST_BINARY") if override: @@ -222,11 +265,25 @@ def rust_binary() -> str | None: "Fix the path, or unset it to fall back to target/{release,debug}." ) return override - for profile in ("release", "debug"): - candidate = REPO_ROOT / "target" / profile / f"tan{_EXE}" - if candidate.exists(): - return str(candidate) - return None + candidates = [ + REPO_ROOT / "target" / profile / f"tan{_EXE}" for profile in ("release", "debug") + ] + existing = [c for c in candidates if c.exists()] + if not existing: + return None + newest_mtime = max(c.stat().st_mtime for c in existing) + tied = [c for c in existing if c.stat().st_mtime == newest_mtime] + if len(tied) > 1: + raise RuntimeError( + "target/release/tan and target/debug/tan report the IDENTICAL " + f"mtime ({newest_mtime!r}): {', '.join(str(c) for c in tied)}. " + "Refusing to pick one silently -- a tie used to break to " + "target/release unconditionally, which is the exact stale-binary " + "bug the mtime rule this function now uses was written to " + "replace. Rebuild one of the two profiles to break the tie, or " + "set TAN_RUST_BINARY= to the one you mean." + ) + return str(tied[0]) def missing_for_live(rust: str | None) -> bool: @@ -244,28 +301,82 @@ def missing_for_live(rust: str | None) -> bool: def empty_tool_inventory(scratch: Path) -> str: - """A ``PATH`` value pointing at one EMPTY scratch directory -- the general - form of ``test_flash_oracle_parity.py``'s ``_pin_tool_inventory`` for a - case whose frozen fixture answer is "no such tool anywhere on PATH", not - "found this specific stand-in". Any ``shutil.which``/``doctor_cmd.on_path`` - probe run against this directory alone reports every name absent, - matching whatever tool inventory a fixture's capture host happened to - lack (tan-cli#313, tan-cli#324) -- ``west`` for - ``test_west_forward_matches_rust`` today, and any other which()-gated - case's absent-tool branch tomorrow, without inventing a second - bespoke per-file helper for each one that comes up. + """A ``PATH`` value that resolves NOTHING except ``which`` itself -- the + general form of ``test_flash_oracle_parity.py``'s ``_pin_tool_inventory`` + for a case whose frozen fixture answer is "no such tool anywhere on PATH", + not "found this specific stand-in". Any ``shutil.which``/ + ``doctor_cmd.on_path`` probe run against this directory reports every + PROBED name absent -- ``west`` for ``test_west_forward_matches_rust`` + today, ``git``/``cmake``/``ninja``/``python3``/``xz``/``wget`` for + ``support-bundle.hostPrerequisites``, and any other which()-gated case's + absent-tool branch tomorrow -- matching whatever tool inventory a + fixture's capture host happened to lack (tan-cli#313, tan-cli#324), + without inventing a second bespoke per-file helper for each one that + comes up. + + Seeds the directory with exactly one file: a symlink to the REAL + ``which`` this host resolves on its own PATH, POSIX only (a no-op on + Windows, whose probe -- ``crate::util::windows_path_lookup`` -- walks + ``%PATH%`` by hand and never spawns an external ``which`` at all). A + directory that is genuinely, literally empty is NOT a clean "nothing on + PATH" answer on POSIX: the oracle's own probe + (``crates/tan-cli/src/util.rs:35-50``, ``command_on_path``) resolves a + tool by SPAWNING ``which `` as a subprocess, and that spawn itself + has to find ``which`` via this SAME (oracle-controlled) PATH. An empty + directory can't resolve ``which`` either, so the probe fails before it + ever answers the question asked -- measured directly: a PATH holding + every one of ``support-bundle``'s six required tools but NOT ``which`` + still reports all six missing, and copying a working ``which`` in + (touching nothing else) makes that same warning vanish entirely. A + literally-empty directory's "everything missing" answer was an artefact + of the unresolvable ``which`` spawn, not a real absence measurement -- + see ``_DEFERRED_VERBS``'s own comment for what the pinned + ``support-bundle`` answer means now that this seeds a working ``which``: + a GENUINE probe that ran and found nothing, not a degenerate one that + could not run at all. REPLACES ``PATH`` outright rather than prepending, for the identical reason ``_pin_tool_inventory`` does: prepending would still let a REAL tool further down the replay host's own PATH be found, which is exactly - the host-dependence this exists to remove. Hand the result to - :func:`compare`'s ``python_env_overrides`` -- e.g. - ``{"PATH": empty_tool_inventory(tmp_path)}`` -- never fabricate the - equivalent for the rust side; see that parameter's own docstring for why - forwarding a PATH pin to the oracle is not a safe substitute. + the host-dependence this exists to remove. + + Hand the result to :func:`compare`'s ``python_env_overrides`` -- e.g. + ``{"PATH": empty_tool_inventory(tmp_path)}`` -- for a FROZEN-REPLAY + caller, and never fabricate the equivalent for the rust side THERE: the + frozen answer is a recorded fixture, and pinning only the python side's + PATH is exactly right in that mode (see that parameter's own docstring + for why forwarding the same pin to the rust side under + ``TAN_PARITY_LIVE=1`` is not a safe substitute -- the whole tan-cli#313/ + #324 bug was one side pinned and the other left to whatever happened to + be installed). That ban is scoped to ``compare()``'s frozen-replay path -- + it does not reach a case that spawns BOTH binaries live on every run + instead (e.g. ``test_deferred_verb_is_a_known_divergence_from_the_ + oracle``): there, applying this SAME value to both sides' subprocess + environments is sound, because it is one identical override applied + twice, symmetrically, not a pin smuggled onto one side only. """ stub_dir = scratch / "empty-path" stub_dir.mkdir(exist_ok=True) + if sys.platform != "win32": + # The seed is NOT optional. A literally-empty PATH makes the oracle's + # POSIX probe unable to resolve `which` ITSELF, so its tool report goes + # degenerate: it names every tool missing because it could not look, + # not because they are absent. A caller pinning that answer measures a + # broken probe. Refuse rather than silently return to it (tan-cli#313, + # tan-cli#324 -- the same silent-degradation class both closed). + real_which = shutil.which("which") + if real_which is None: + raise RuntimeError( + "empty_tool_inventory() cannot seed `which` into the stub PATH: " + "shutil.which('which') returned None on this POSIX host. Without " + "it the oracle's tool probe cannot run at all and reports every " + "tool missing for the wrong reason -- refusing rather than " + "pinning that artefact." + ) + link = stub_dir / "which" + if not link.exists(): + os.symlink(real_which, link) + assert link.exists(), f"failed to seed `which` into {stub_dir}" return str(stub_dir) diff --git a/python/tests/parity/test_oracle_parity.py b/python/tests/parity/test_oracle_parity.py index 250f7fbc..59cf90f0 100644 --- a/python/tests/parity/test_oracle_parity.py +++ b/python/tests/parity/test_oracle_parity.py @@ -64,8 +64,9 @@ #: Every case: argv, the surface it is scoped to, and -- when the port cannot #: satisfy it yet -- why. A ``None`` reason means the case runs for real. CASES = [ - # The extension's acceptance probe. Compared by SHAPE: 0.5.0-dev vs - # 0.4.1-dev is a deliberate, permanent difference (python/tan/version.py). + # The extension's acceptance probe. Compared by SHAPE: the port's + # 0.5.0-rc3 (python/tan/version.py) vs the oracle's 0.4.1 (Cargo.toml) is + # a deliberate, permanent difference. (["--version"], VERSION, None), # A usage error must put its diagnosis on stderr and leave stdout EMPTY -- # the extension parses stdout whole, so one stray byte breaks it. clap and @@ -609,6 +610,443 @@ def test_sdk_switch_unresolvable_version_is_a_known_divergence_from_the_oracle(w assert (p_code, [i["code"] for i in p_out["issues"]]) == (1, ["sdk.not-ported"]) +# --- v0.6.0's named command-surface parity ---------------------------------- +# +# v0.6.0's own milestone goal states, verbatim: "Full command-surface parity +# with the v0.4.1 oracle: model, new-som, monitor, faultdecode, the +# introspection set, renode, and the seven entirely-unported verbs." Nothing +# above this point in the file ever runs any of those verbs -- this section is +# what actually reads that claim, one case per verb, against a REAL run of the +# oracle (never inferred from `crates/` or a docstring). +# +# These do NOT go through `compare()`/`rust_run()`: both replay a COMMITTED +# fixture by default (`oracle_fixtures.resolve`), and adding a fixture entry +# for a brand-new case is a separate, deliberate act with its own capture +# recipe (`oracle_fixtures/PROVENANCE.txt`) -- out of scope for this change. +# Instead these spawn `RUST` directly, every run, skipped only when no oracle +# binary is built (`_ORACLE_REQUIRED`, keyed on BINARY PRESENCE, not +# `TAN_PARITY_LIVE` like `LIVE_GATE` above): reusing `LIVE_GATE` here would NOT +# skip when `RUST is None`, since `missing_for_live` only ever fires under +# `TAN_PARITY_LIVE=1`, and would instead crash inside `subprocess.run([None, +# ...])`. `ci.yml`'s `python` job never runs `cargo build`, so `RUST is None` +# there and these cleanly skip; `parity.yml`'s `python-tests` job DOES +# (`cargo build --locked --bin tan`), so there -- and on any host with +# `target/{release,debug}/tan` already built, this one included -- these +# genuinely spawn both binaries fresh, in the SAME `work_dir`/`home`, and diff +# them for real. Because both sides share that one scratch `work_dir`, an +# embedded absolute path is already byte-comparable with no +# `oracle_fixtures.scrub` needed, unlike the frozen-replay cases above (whose +# fixture was captured from a DIFFERENT scratch dir than any replay). +# +# `_ORACLE_REQUIRED` skips on binary ABSENCE only -- never on a resolved binary +# being the WRONG one. `rust_binary()` picks the MOST RECENTLY BUILT of +# target/{release,debug}/tan (its own docstring), so a resolved-but-wrong +# binary today means either an inverted or TIED mtime between the two +# profiles (a tie is refused outright inside `rust_binary()` itself -- see +# that function) or an explicit TAN_RUST_BINARY naming the wrong one. This +# comment used to describe an OLDER rule -- a fixed release-over-debug +# preference -- and the failure that rule caused: measured on a real host, a +# stale `target/release/tan` (`tan 0.1.1`, weeks old) sat next to a fresh +# `target/debug/tan` (`tan 0.4.1`), `RUST` silently bound to the stale one +# because release was tried unconditionally regardless of either file's age, +# and every case below -- which, unlike the `LIVE_GATE` cases above, has no +# frozen fixture to fall back to -- measured itself against a binary that +# predates half the commands it runs: 7 of these failed, with no signal that +# the oracle, not the port, was wrong. Turning that into a SKIP (e.g. +# widening `_ORACLE_REQUIRED`'s condition to also skip on a version mismatch) +# would reopen exactly the hole `missing_for_live`'s own docstring refuses -- +# "a quiet skip here would hide exactly the gap that function exists to +# surface" (tan-cli#272) -- so `pinned_oracle`, now a session-scoped, autouse +# fixture in `conftest.py` that every module under `tests/parity/` inherits +# (not just this section), FAILS the run instead, loudly, naming the +# mismatch. `_ORACLE_REQUIRED` below composes only the presence skip: the +# content check no longer needs opting into per case. + + +def _oracle_required(fn): + """`@_ORACLE_REQUIRED`'s actual decorator: skips on binary ABSENCE only. + The version-WRONGNESS check (`pinned_oracle`) is a session-scoped, + autouse fixture in `conftest.py` now, so every case tagged + `@_ORACLE_REQUIRED` gets it for free the same way every OTHER module + under `tests/parity/` does -- nothing here opts it in by hand any more.""" + fn = pytest.mark.skipif( + RUST is None, + reason="needs a built Rust tan; run `cargo build --bin tan` (or set TAN_RUST_BINARY)", + )(fn) + return fn + + +_ORACLE_REQUIRED = _oracle_required + + +@_ORACLE_REQUIRED +@pytest.mark.parametrize( + "argv,exit_code", + [ + (["explain", "--format", "json"], 0), + (["explain", "--template", "bogus-template", "--format", "json"], 1), + (["explain", "--target", "bogus-target", "--format", "json"], 1), + ], + ids=["overview", "unknown-template", "unknown-target"], +) +def test_explain_matches_the_oracle(argv, exit_code, work_dir, tmp_path): + """tan-cli#257 (the introspection set). `explain` reads no board.yaml and + no alp-sdk checkout at all -- it is a static topic index over the + template/target catalogues baked into both binaries -- and its envelope + is byte-identical on every invocation measured here: the overview, an + unknown ``--template``, and an unknown ``--target``. + + ``exit_code`` is PINNED per case (0 for the overview, 1 for each + unknown-topic refusal), measured directly rather than left as a bare + ``r_code == p_code``: that comparison, plus ``oracle._run``'s own + degrade-on-unparseable-stdout fallback (``{'__raw__': ...}``), lets two + binaries that both wrote NOTHING to stdout (say, both crashing before + printing) compare equal at exit ``0 == 0`` having measured nothing at + all. The explicit non-empty, non-``__raw__`` envelope check below closes + that the rest of the way.""" + home = tmp_path / "home" + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_code == p_code == exit_code + assert r_out and "__raw__" not in r_out, r_out + assert p_out and "__raw__" not in p_out, p_out + assert r_out == p_out + + +# No `image`-missing-manifest case here, unlike its introspection-set siblings +# above and below: `test_image_missing_manifest` in `test_image_size_oracle.py` +# already covers this exact surface (exit 1, byte-identical envelope, +# including the message's embedded absolute path) and does so with NO +# divergence to pin -- `image`'s refusal message carries no OS-error tail to +# normalise or narrow, unlike `size` just below. A case living here would +# duplicate that assertion verbatim while adding nothing (measured: the two +# read byte-for-byte identical envelopes on this oracle), so it was dropped +# rather than kept as a second copy of the same check. +# +# Honestly, the drop gives up two things `size`'s own case below keeps, and +# both are acceptable for the identical reason -- no divergence exists for +# `image` to hide from either axis: +# +# * LIVE-SPAWN coverage. `test_image_missing_manifest` runs through +# `assert_parity` -> `_rust_run` -> `oracle_fixtures.resolve`, which REPLAYS +# a frozen fixture unless `TAN_PARITY_LIVE=1` is set. `size`'s case here +# uses `@_ORACLE_REQUIRED`, which spawns the oracle live unconditionally +# whenever a binary is present. Dropping `image` here means it is never +# exercised by THIS file's unconditional-live mode, only by a frozen replay +# or an opt-in live run elsewhere. +# * DEFAULT-BUILD-ROOT coverage. `test_image_missing_manifest` always passes +# an explicit `--build-root br`; `size`'s case here passes no +# `--build-root` at all, resolving the DEFAULT `work_dir/build/system- +# manifest.yaml`. `image`'s missing-manifest path is never measured against +# the default build root anywhere in this repo. +# +# Both gaps are safe to leave open because they are gaps in HOW the answer is +# produced, not in WHAT could go wrong: `image`'s refusal message is a fixed +# string plus an embedded path with no OS-error tail, so it cannot drift +# between a frozen fixture and a live run, or between an explicit and a +# default build root, the way `size`'s OS-`errno` rendering can. A live, +# default-build-root `image` case would measure the identical envelope this +# file already confirmed byte-identical under `--build-root br`, adding +# coverage of the harness's own plumbing, not of `image` itself. + +@_ORACLE_REQUIRED +def test_renode_no_sdk_matches_the_oracle(work_dir, tmp_path): + """tan-cli#77. ``renode`` with no alp-sdk resolvable and no manifest is + byte-identical -- the whole ``data`` placeholder shape (empty sku/repl/ + resc/elf, the derived ``logPath``) included.""" + home = tmp_path / "home" + argv = ["renode", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_code == p_code == 1 + assert r_out == p_out + + +@_ORACLE_REQUIRED +def test_size_missing_manifest_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#257 (the introspection set). Exit code and issue CODE match; + the message's trailing OS-error text does not, and permanently cannot -- + it is Rust's ``io::Error`` Display ("No such file or directory (os error + 2)") against Python's ``OSError`` str ("[Errno 2] No such file or + directory: ''"), two runtimes rendering the identical ``ENOENT``. + Pinned literally on BOTH the matching prefix and the diverging tail, per + this file's own rule against narrowing a comparison down to "exit code + only" to make it pass -- a change to either rendering, or the two + converging, must fail this test rather than pass it silently. + + Overlaps `test_size_missing_manifest` in `test_image_size_oracle.py` on + the SAME setup (an empty ``build/system-manifest.yaml``-less project) but + NOT on what it asserts: that test's `_normalise` collapses this exact + OS-error tail into a placeholder (``run \\`tan build\\` first + ().``) before comparing, deliberately treating the wording as + immaterial -- this test asserts the opposite, pinning the literal, + un-normalised text on BOTH sides as the divergence itself. It is also, + unlike that one, an unconditional LIVE spawn (`_ORACLE_REQUIRED` is keyed + on binary presence, not `TAN_PARITY_LIVE`; see the module comment above + the v0.6.0 section), where the counterpart replays a committed fixture by + default and only spawns the oracle under `TAN_PARITY_LIVE=1`.""" + home = tmp_path / "home" + argv = ["size", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_code == p_code == 1 + assert [i["code"] for i in r_out["issues"]] == ["size.manifest-unavailable"] + assert [i["code"] for i in p_out["issues"]] == ["size.manifest-unavailable"] + manifest_path = str(work_dir / "build" / "system-manifest.yaml") + prefix = f"no system-manifest.yaml at {manifest_path}; run `tan build` first (" + r_message = r_out["issues"][0]["message"] + p_message = p_out["issues"][0]["message"] + assert r_message == prefix + "No such file or directory (os error 2))." + assert p_message == prefix + f"[Errno 2] No such file or directory: '{manifest_path}')." + # Everything OUTSIDE the message -- exit code, `data`, the issue code -- + # is a real match, not just coincidentally unchecked here. + assert {**r_out, "issues": []} == {**p_out, "issues": []} + + +@_ORACLE_REQUIRED +def test_run_no_sdk_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#257 (the introspection set). Exit code and issue CODE match + (``build.plan-unavailable``, 1); the message's wording does not -- the + oracle names three remedies (``--sdk-root``, ``tan sdk switch``, ``tan + bootstrap``) where the port names one (``--sdk-root`` or a sibling + checkout), and neither is a substring of the other. Pinned literally, not + narrowed to the codes alone. + + Everything OUTSIDE the message -- exit code, ``data``, the issue code -- + is a real match too, not just coincidentally unchecked here: mirrors the + whole-envelope-minus-message bar + ``test_size_missing_manifest_is_a_known_divergence_from_the_oracle`` sets + one function above, measured true for ``run`` the same way.""" + home = tmp_path / "home" + argv = ["run", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_code == p_code == 1 + assert [i["code"] for i in r_out["issues"]] == ["build.plan-unavailable"] + assert [i["code"] for i in p_out["issues"]] == ["build.plan-unavailable"] + r_message = r_out["issues"][0]["message"] + p_message = p_out["issues"][0]["message"] + assert r_message == ( + "no alp-sdk checkout found — pass `--sdk-root `, pin one " + "with `tan sdk switch `, set it in settings, or run " + "`tan bootstrap`. The build-plan comes from the SDK's " + "`alp_orchestrate --emit build-plan`." + ) + assert p_message == ( + "no alp-sdk checkout found -- pass `--sdk-root ` or run from a " + "project beside one. Planning reads the SDK's `metadata/**`." + ) + assert r_out["data"] is None + assert p_out["data"] is None + assert {**r_out, "issues": []} == {**p_out, "issues": []} + + +@_ORACLE_REQUIRED +def test_model_bare_invocation_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#253. The oracle's ``model`` is a generic ARGS-forwarding + wrapper (``[ARGS]...`` in its own ``--help``, shared machinery with + ``new-som``/``monitor``/``faultdecode``) that resolves an alp-sdk + checkout before doing anything else and refuses + ``model.failed``/"alp-sdk root is unresolved", exit 2, when none is + found. The port re-implements ``model`` natively with its own ``build`` + subcommand (``Usage: tan model [OPTIONS] [SUBCOMMAND]``) and never + touches an SDK at this step, refusing instead with + ``model.unknown-subcommand``, exit 1, when no subcommand is named. + Neither the exit code nor the issue code agree -- both pinned, not + narrowed to the one thing they share (a ``command: "model"`` JSON + envelope shape).""" + home = tmp_path / "home" + argv = ["model", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_out["command"] == p_out["command"] == "model" + assert (r_code, [i["code"] for i in r_out["issues"]]) == (2, ["model.failed"]) + assert (p_code, [i["code"] for i in p_out["issues"]]) == (1, ["model.unknown-subcommand"]) + + +@_ORACLE_REQUIRED +def test_new_som_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#254. The port's ``new-som`` declares no ``--format`` option at + all (only ``--sku``/``--soc-ref``/…/``--force``; confirmed via + ``new-som --help``), so ``--format json`` never reaches a + ``new-som``-shaped envelope -- Click raises a USAGE error the ROOT + handler wraps as ``command: "cli"`` / ``cli.parse-error`` instead, where + the oracle's own ``--format json`` reaches a real ``command: "new-som"`` + refusal (``new-som.failed``, exit 2). Even the bare, ``--format``-free + invocation both sides genuinely answer disagrees: exit 2 vs exit 1, and + the message is not the same sentence reworded -- the port adds a + ``git clone`` suggestion the oracle never had.""" + home = tmp_path / "home" + r_code, _ = _run([RUST], ["new-som"], work_dir, home) + p_code, _ = _run(python_command(), ["new-som"], work_dir, home) + assert r_code == 2 + assert p_code == 1 + _, r_json_out = _run([RUST], ["new-som", "--format", "json"], work_dir, home) + _, p_json_out = _run(python_command(), ["new-som", "--format", "json"], work_dir, home) + assert r_json_out["command"] == "new-som" + assert [i["code"] for i in r_json_out["issues"]] == ["new-som.failed"] + assert p_json_out["command"] == "cli" + assert [i["code"] for i in p_json_out["issues"]] == ["cli.parse-error"] + + +@_ORACLE_REQUIRED +def test_monitor_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#255. The oracle's ``monitor`` shares the same SDK-resolving + forwarder as ``model``/``new-som``/``faultdecode`` and refuses + ``monitor.failed``/"alp-sdk root is unresolved", exit 2, with no SDK + resolvable. The port's ``monitor`` is a deliberate redesign + (``monitor_cmd.py``'s own docstring: "no alp-sdk checkout required, + unlike `model`" -- "a deliberate, documented improvement, not a + regression") that never touches an SDK at all; with no ``--port`` given + it refuses at exit 1 with EITHER ``monitor.pyserial-missing`` (pyserial + not installed in THIS interpreter) or ``monitor.no-port`` (pyserial + present, no port named) -- which of the two fires depends on this host's + own package set, so both are accepted here rather than pinning the one + this authoring host happened to hit (tan-cli#313/#324 is exactly the + class of bug that would be). + + This is NOT the same tool-inventory gap `_DEFERRED_VERBS` pins PATH + against: pyserial is an interpreter PACKAGE, invisible to any PATH pin. + The either-or is real and stays real across this repo's own two CI legs, + named explicitly rather than left as an unexplained widening: + `parity.yml`'s `python-tests` job installs `-e ".[monitor]"` (pyserial + present -> `monitor.no-port`), `ci.yml`'s `python` job installs the bare + package with no extras (`pip install -e ./python`, pyserial absent -> + `monitor.pyserial-missing`) -- both are legitimate, currently-running CI + configurations, not a hypothetical.""" + home = tmp_path / "home" + argv = ["monitor", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert (r_code, [i["code"] for i in r_out["issues"]]) == (2, ["monitor.failed"]) + assert p_code == 1 + p_codes = [i["code"] for i in p_out["issues"]] + assert p_codes in (["monitor.pyserial-missing"], ["monitor.no-port"]), p_codes + + +@_ORACLE_REQUIRED +def test_faultdecode_is_a_known_divergence_from_the_oracle(work_dir, tmp_path): + """tan-cli#256. Exit codes COINCIDE at 2 here -- which is exactly why + this case exists: pinned on the issue code and ``command`` field too, so + a narrowed "exit code only" comparison could never quietly stand in for + a real match (this file's own stated trap). The oracle forwards to + ``alp faultdecode`` and refuses on the SAME unresolved-SDK guard as + ``model``/``monitor``/``new-som``. The port re-implements + ``faultdecode`` natively (pure ARMv8-M register arithmetic, no SDK read + at all -- see ``faultdecode --help``'s own text) and refuses instead + because no fault register was supplied on the command line.""" + home = tmp_path / "home" + argv = ["faultdecode", "--format", "json"] + r_code, r_out = _run([RUST], argv, work_dir, home) + p_code, p_out = _run(python_command(), argv, work_dir, home) + assert r_code == p_code == 2 + assert r_out["command"] == "faultdecode" + assert [i["code"] for i in r_out["issues"]] == ["faultdecode.failed"] + assert p_out["command"] == "cli" + assert [i["code"] for i in p_out["issues"]] == ["cli.parse-error"] + + +#: ``(verb, rust_exit_code, rust_issue_codes)`` -- measured directly against +#: the oracle in an empty project with no alp-sdk resolvable, not inferred. +#: Every one of these is a REAL, distinct outcome per verb; the port +#: collapses all seven to the identical ``cli.command-deferred`` shape +#: (tan-cli#260). +#: +#: ``support-bundle``'s third issue, ``support-bundle.hostPrerequisites``, is a +#: TOOL PROBE -- the oracle checks a built-in fallback tool list (measured: +#: ``git cmake python3 ninja xz wget``) against ``PATH`` and only raises it when +#: something is missing. On a host where every one of those happens to resolve +#: it does not fire at all (two issues, not three); under a stripped +#: ``PATH=/usr/bin:/bin`` (has everything but ``ninja``) it fires naming just +#: ``ninja``; under a truly empty ``PATH`` it fires naming all six. This is +#: exactly tan-cli#313/#324's class of bug reintroduced -- pinning ``[sdkRoot, +#: boardYaml]`` here silently encoded THIS capture host's tool inventory. Fixed +#: by pinning to `empty_tool_inventory` below, the same fixture +#: `test_west_forward_matches_rust` already uses for the identical class of +#: bug -- the one outcome that does not depend on what happens to be +#: installed on whichever host runs this suite. +#: +#: The three-code answer below is now a GENUINE "all six absent" measurement, +#: not an artefact: on POSIX the oracle's tool probe resolves each name by +#: SPAWNING ``which ``, and a PATH pointing at a directory that is +#: literally empty can't resolve ``which`` either, so every probe failed +#: before it ever ran for real -- measured directly, a PATH holding all six +#: required tools but not ``which`` still reported all six missing, which +#: means the pin used to read the right three codes for the wrong reason. +#: `empty_tool_inventory` now seeds its directory with a working ``which`` +#: symlink for exactly this reason (see that function's own docstring); the +#: three codes here are unchanged after re-measuring against the fixed pin -- +#: ``git cmake python3 ninja xz wget`` still resolve to nothing in a +#: directory holding only ``which`` -- so this is a correctness fix to HOW +#: the answer is produced, not a change to the answer itself. +#: +#: Measured directly (not asserted) that the other six verbs are PATH-inert: +#: run each one's argv under this host's real PATH and under the pinned PATH, +#: rust and python both, and diff -- identical on every one of the twelve +#: (six verbs x two sides) except this row. Only `support-bundle` branches on +#: a tool probe among the seven; the pin below is applied to all seven anyway +#: (cheap, and it is what keeps the whole parametrized set on one +#: deterministic footing) rather than special-cased to just this one row. +_DEFERRED_VERBS = [ + ("scaffold", 2, ["scaffold.name-required"]), + ("completion", 0, []), + ("diff", 2, ["diff.board-yaml-missing"]), + ("pinmux", 0, ["pinmux.no-target", "pinmux.sdk-root-unresolved"]), + ("inspect", 0, ["inspect.board-yaml-missing"]), + ("trace", 2, ["trace.sdk-root-unresolved"]), + ( + "support-bundle", + 4, + [ + "support-bundle.sdkRoot", + "support-bundle.boardYaml", + "support-bundle.hostPrerequisites", + ], + ), +] + + +@_ORACLE_REQUIRED +@pytest.mark.parametrize( + "verb, rust_exit, rust_issue_codes", _DEFERRED_VERBS, ids=[v[0] for v in _DEFERRED_VERBS] +) +def test_deferred_verb_is_a_known_divergence_from_the_oracle( + verb, rust_exit, rust_issue_codes, work_dir, tmp_path +): + """tan-cli#260: the seven verbs v0.6.0 names as "entirely-unported". + Every one is registered in ``tan/cli.py`` (each appears in + ``tan --help``), but the command body is a uniform stub: exit 1, issue + ``cli.command-deferred``, a message naming this tracking issue -- + verified identical in shape across all seven, not just asserted to + differ from whatever the oracle happens to say. The oracle, by + contrast, answers each verb for real, and no two of the seven share an + outcome (a warning-only success, three different flavours of exit 2, + and one exit 4) -- each pinned here from an actual run, not copied from + a docstring. + + Both sides spawn under ``PATH`` pinned to `empty_tool_inventory`'s scratch + directory (empty of every PROBEABLE tool, seeded with only a working + ``which`` -- see that function's own docstring for why the seed matters), + SYMMETRICALLY -- unlike `compare()`'s ``python_env_overrides`` (which + only ever pins the python side, because in frozen-replay mode the rust + side never spawns at all), this test spawns both binaries live on every + run, so pinning only one side would not even keep them on the same + footing, let alone a host-independent one. See `_DEFERRED_VERBS`'s own + comment for what this pin is actually for: ``support-bundle`` alone, + among the seven, branches on a tool probe (tan-cli#313/#324's class of + bug).""" + home = tmp_path / "home" + argv = [verb, "--format", "json"] + env_overrides = {"PATH": empty_tool_inventory(tmp_path)} + r_code, r_out = _run([RUST], argv, work_dir, home, env_overrides=env_overrides) + p_code, p_out = _run(python_command(), argv, work_dir, home, env_overrides=env_overrides) + assert r_code == rust_exit, (verb, r_out) + assert [i["code"] for i in r_out["issues"]] == rust_issue_codes, (verb, r_out) + assert p_code == 1, (verb, p_out) + assert [i["code"] for i in p_out["issues"]] == ["cli.command-deferred"], (verb, p_out) + assert "tan-cli/issues/260" in p_out["issues"][0]["message"] + + # --- the harness must be able to go red ------------------------------------ # # A parity run that cannot fail is worse than no parity run: it reads as @@ -635,7 +1073,9 @@ def test_harness_reports_a_planted_exit_code_difference(work_dir, tmp_path): "print('tan v0.5-dev')", # ...and the shape must cover the WHOLE of stdout. A prefix-anchored # match let both of these through as parity, on the one case that - # actually runs today. Rust prints exactly `tan 0.4.1-dev`. + # actually runs today. Rust prints exactly `tan 0.4.1` + # (oracle.PINNED_ORACLE_VERSION owns that spelling); the strings below + # are deliberately fabricated stdout, not either binary's real output. "print('tan 0.5.0-dev'); print('LEAKED EXTRA STDOUT LINE')", "print('tan 9.9.9 THIS IS NOT TAN AT ALL')", ], From af0146555d6402dcbf2d8e4a8b7ff91e76cc3bd1 Mon Sep 17 00:00:00 2001 From: Caner Alp <109098482+alpCaner@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:35:11 +0000 Subject: [PATCH 10/10] test(gates): register generate.write-escapes-project, and re-pin the bootstrap warn count Merging dev put the tan-cli#224 emit-site gate in one tree with two commits that landed while this branch was in flight, and it caught both. fcb2153 (tan-cli#325, `fix(init,generate): confine writes to the project after symlink resolution`) added `generate.write-escapes-project` -- raised as a GenerateError with ExitCode.WRITE_FAILURE when resolve_confined finds a target's output path resolving outside the project root -- and registered it nowhere. That is ungated on both sides of the seam at once: this repo's registry-driven checks never see it, and the published envelope-contract.json is built from that same registry, so alp-sdk-vscode cannot see it either. Registered `reserved`/`consumer: none`, which costs nothing since a reserved code may still be renamed freely. It is unregistered on dev right now, not only here -- this registration fixes it for everyone at merge. 518ac8c (tan-cli#334) split the single `zephyr-base-incompatible` warn into a found/else pair so the message can name the evidence, taking that helper from 16 call sites to 17. One code, two sites, already registered. Checked before bumping, which is the entire point of pinning the count: it forced the look rather than allowing a reflexive increment. --- contract/issue-codes.json | 9 +++++++++ .../tests/gates/test_every_issue_code_is_registered.py | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/contract/issue-codes.json b/contract/issue-codes.json index ae3e2cc1..1ad0b02f 100644 --- a/contract/issue-codes.json +++ b/contract/issue-codes.json @@ -1431,6 +1431,15 @@ "literal": "\"generate.would-overwrite\"", "note": "Constructed as the whole literal at a `GenerateError` call site in `generate_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." }, + { + "code": "generate.write-escapes-project", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.write-escapes-project\"", + "note": "Raised as a `GenerateError` with `ExitCode.WRITE_FAILURE` when `resolve_confined` finds a target's output path resolving outside the project root, refusing the whole run rather than any target. Added by tan-cli#325 (`fix(init,generate): confine writes to the project after symlink resolution`) and caught UNREGISTERED by the tan-cli#224 emit-site gate the first time the two met in one tree -- the gate's first catch on code it did not itself ship. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, { "code": "image.manifest-invalid", "status": "reserved", diff --git a/python/tests/gates/test_every_issue_code_is_registered.py b/python/tests/gates/test_every_issue_code_is_registered.py index f9595d48..848ba549 100644 --- a/python/tests/gates/test_every_issue_code_is_registered.py +++ b/python/tests/gates/test_every_issue_code_is_registered.py @@ -712,7 +712,12 @@ def _walk(node: ast.AST) -> None: expr="code", attr="warn", arg_index=0, - expected_calls=16, + # 17, not 16, since dev's 518ac8c (tan-cli#334) split the single + # `zephyr-base-incompatible` warn into a found/else pair so the message + # can name the evidence. Two call sites, ONE code, already registered + # (`bootstrap.zephyr-base-incompatible`) -- checked before bumping, + # which is the whole point of the count: it forced the look. + expected_calls=17, sites=1, ), ("tan/commands/bootstrap_cmd.py", "_refusal"): dict(