diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 0f0b08b7..9f9ee917 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -23,6 +23,21 @@ name: parity +# tan-cli#213: surface `client_payload.sdk_ref` in the Actions API's own +# `display_title` (what `run-name` sets) rather than only a job-log `::notice::` +# (see the "resolve alp-sdk ref" step below) -- alp-sdk's dispatch-confirmation +# poll can then filter on the exact ref it sent instead of "any run created +# after our dispatch epoch", which a concurrent push or other sender also +# satisfies. Only `repository_dispatch` carries `client_payload`; the other +# three triggers below (`push`, `pull_request`, and `workflow_call` from +# release.yml) render an empty string instead, which is NOT a blank/broken +# title -- GitHub's own rule is that an omitted-or-whitespace-only `run-name` +# falls back to the event-specific default (the commit message on `push`, the +# PR title on `pull_request`), and that default is strictly more informative +# than a constant `parity (push)` string would be. Same empty-string-means- +# "use the default" idiom as the `ref:` step below. +run-name: ${{ github.event_name == 'repository_dispatch' && format('parity (sdk {0})', github.event.client_payload.sdk_ref) || '' }} + on: # Direct pushes to `main` (an admin merge, a hotfix, a back-merge) open no PR, # so without this the three jobs below never ran on those commits at all -- diff --git a/contract/issue-codes.json b/contract/issue-codes.json index 7876bb49..1ad0b02f 100644 --- a/contract/issue-codes.json +++ b/contract/issue-codes.json @@ -710,6 +710,1329 @@ "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": "generate.write-escapes-project", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/generate_cmd.py", + "literal": "\"generate.write-escapes-project\"", + "note": "Raised as a `GenerateError` with `ExitCode.WRITE_FAILURE` when `resolve_confined` finds a target's output path resolving outside the project root, refusing the whole run rather than any target. Added by tan-cli#325 (`fix(init,generate): confine writes to the project after symlink resolution`) and caught UNREGISTERED by the tan-cli#224 emit-site gate the first time the two met in one tree -- the gate's first catch on code it did not itself ship. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.manifest-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/image_cmd.py", + "literal": "\"image.manifest-invalid\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `image_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "image.manifest-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/image_cmd.py", + "literal": "\"image.manifest-unavailable\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `image_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.board-yaml-unreadable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.board-yaml-unreadable\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.board-yaml-unsupported", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.board-yaml-unsupported\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.example-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.example-not-found\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.example-unreadable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.example-unreadable\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.internal-failure", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.internal-failure\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-cores", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-cores\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-example", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-example\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-name", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-name\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-som", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-som\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.invalid-template", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.invalid-template\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.sdk-root-unresolved\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "init.template-unreadable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/init_cmd.py", + "literal": "\"init.template-unreadable\"", + "note": "Constructed as the whole literal at a `InitError` call site in `init_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.board-yaml-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "\"kconfig.board-yaml-invalid\"", + "note": "Constructed as the whole literal at a `_CoreResolutionError` call site in `kconfig_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "\"kconfig.board-yaml-missing\"", + "note": "Constructed as the whole literal at a `_CoreResolutionError` call site in `kconfig_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "kconfig.core-ambiguous", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/kconfig_cmd.py", + "literal": "\"kconfig.core-ambiguous\"", + "note": "Constructed as the whole literal at a `_CoreResolutionError` call site in `kconfig_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.board-yaml-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.board-yaml-invalid\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.board-yaml-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.board-yaml-missing\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.build-timeout", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.build-timeout\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.python-too-old", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.python-too-old\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "model.sdk-root-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/model_cmd.py", + "literal": "\"model.sdk-root-unresolved\"", + "note": "Constructed as the whole literal at a `ModelError` call site in `model_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.launch-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.launch-failed\"", + "note": "Constructed as the whole literal at a `MonitorError` call site in `monitor_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.no-port", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.no-port\"", + "note": "Constructed as the whole literal at a `MonitorError` call site in `monitor_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "monitor.pyserial-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/monitor_cmd.py", + "literal": "\"monitor.pyserial-missing\"", + "note": "Constructed as the whole literal at a `MonitorError` call site in `monitor_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.argv-rejected", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.argv-rejected\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.binary-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.binary-missing\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.cpu-halted", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.cpu-halted\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.descriptor", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.descriptor\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.descriptor-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.descriptor-missing\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.elf-missing", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.elf-missing\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.exited-nonzero", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.exited-nonzero\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.expect-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.expect-not-found\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.image-bundle-unused", + "status": "reserved", + "severity": "info", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.image-bundle-unused\"", + "note": "Constructed as the whole literal at a `_issue` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.manifest-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.manifest-invalid\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.manifest-schema", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.manifest-schema\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.manifest-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.manifest-unavailable\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.run-failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.run-failed\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sdk-root-not-found", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.sdk-root-not-found\"", + "note": "Constructed as the whole literal at a `fail` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.sku-unresolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.sku-unresolved\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "renode.slice", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/renode_cmd.py", + "literal": "\"renode.slice\"", + "note": "Constructed as the whole literal at a `fail_sdk` call site in `renode_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.manifest-invalid", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.manifest-invalid\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `size_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "size.manifest-unavailable", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/size_cmd.py", + "literal": "\"size.manifest-unavailable\"", + "note": "Constructed as the whole literal at a `_error_outcome` call site in `size_cmd.py` (`_FULL_CODE_CALLABLES` in python/tests/gates/test_every_issue_code_is_registered.py), several call frames from the eventual `Issue(err.code, ...)` re-emit -- invisible to a scan scoped to `Issue(\"family.code\", ...)`/`code=\"family.code\"` alone. Found and registered while remediating tan-cli#224's own review (the gate's first version missed this whole emit shape). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.boardYaml", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"boardYaml\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.bootstrapManifest", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"bootstrapManifest\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.homePath", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"homePath\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.hostPrerequisites", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"hostPrerequisites\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.hostPython", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"hostPython\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.jlink", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"jlink\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.longPaths", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"longPaths\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.pythonFloor", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"pythonFloor\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.sdk", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"sdk\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.sdkProvenance", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"sdkProvenance\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.setools", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"setools\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.sevenZip", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"sevenZip\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.venvProvenance", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"venvProvenance\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.west", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"west\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.westResolved", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"westResolved\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.workspace", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"workspace\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrSdk", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrSdk\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrSdkAvailableForHost", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrSdkAvailableForHost\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrVersion", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrVersion\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "doctor.zephyrWorkspace", + "status": "reserved", + "severity": "warning", + "consumer": "none", + "emittedBy": "python/tan/commands/doctor_cmd.py", + "literal": "\"zephyrWorkspace\"", + "note": "The `doctor.` prefix is applied by `checks_to_issues()`'s `check.code or f\"doctor.{check.name}\"`, mirroring the Rust oracle's own `doctor.` convention verbatim (that function's own docstring says so) -- camelCase, not this registry's usual kebab-case, because the suffix is the `Check(...)` construction's `name` argument, not a hand-written code fragment. Resolved out of an `_ACKNOWLEDGED_CEILINGS` entry into `_RESOLVABLE_HELPERS` while remediating tan-cli#224's own review: the ceiling's stated cost (\"a materially bigger audit\") did not survive measurement -- 48 `Check(...)` call sites, every one literal. Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "migrate.failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/west_forward_cmd.py", + "literal": ".failed", + "note": "The mirrored `f\"{subcommand}.failed\"` shape (family substituted, suffix fixed) at `python/tan/commands/west_forward_cmd.py`'s `_run_forward` -- the reverse of `bootstrap.`-style prefixing, and a second live call site of the exact defect tan-cli#224 reports (a code assembled by interpolation no literal scan can see) found while remediating that issue's own review. `subcommand` is closed to the three literal strings `tan migrate`'s own Typer command passes (`migrate`/`lock`/`quality`, `cli.py`). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "lock.failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/west_forward_cmd.py", + "literal": ".failed", + "note": "The mirrored `f\"{subcommand}.failed\"` shape (family substituted, suffix fixed) at `python/tan/commands/west_forward_cmd.py`'s `_run_forward` -- the reverse of `bootstrap.`-style prefixing, and a second live call site of the exact defect tan-cli#224 reports (a code assembled by interpolation no literal scan can see) found while remediating that issue's own review. `subcommand` is closed to the three literal strings `tan lock`'s own Typer command passes (`migrate`/`lock`/`quality`, `cli.py`). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." + }, + { + "code": "quality.failed", + "status": "reserved", + "severity": "error", + "consumer": "none", + "emittedBy": "python/tan/commands/west_forward_cmd.py", + "literal": ".failed", + "note": "The mirrored `f\"{subcommand}.failed\"` shape (family substituted, suffix fixed) at `python/tan/commands/west_forward_cmd.py`'s `_run_forward` -- the reverse of `bootstrap.`-style prefixing, and a second live call site of the exact defect tan-cli#224 reports (a code assembled by interpolation no literal scan can see) found while remediating that issue's own review. `subcommand` is closed to the three literal strings `tan quality`'s own Typer command passes (`migrate`/`lock`/`quality`, `cli.py`). Pre-consumer -- nothing in alp-sdk-vscode matches this code yet, so renaming or dropping it is not a breaking wire change. Promote to `frozen` (filling in consumer/consumerEffect for real) the moment a consumer binds to it." } ] } 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/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/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/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) 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 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]] 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..848ba549 --- /dev/null +++ b/python/tests/gates/test_every_issue_code_is_registered.py @@ -0,0 +1,1379 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tan-cli#224: the Python emit-site gate the Rust one cannot stand in for. + +`crates/tan-cli/tests/contract.rs` carries a PAIR of tests -- +`every_emitted_issue_code_is_registered` (tan-cli#219: walks every literal +`code: "family.name"` in `crates/` and asserts it is in +`contract/issue-codes.json` at some status) and +`every_prefixed_issue_code_is_registered` (tan-cli#224 itself: a DECLARED +list of `PREFIXING_SITES`, because a code assembled as +`format!("bootstrap.{code}")` from a bare suffix never appears as one whole +literal and the first test structurally cannot see it). Both landed in +commit 78d8308. + +`crates/` ships to NOBODY -- the release assets are PyInstaller freezes of +`python/tan` (tan-cli#271) -- so on the surface that actually reaches a +customer, NEITHER direction of this gate existed until this file. The +prefixing shape is not hypothetical on the Python side either: +`bootstrap_cmd.py`, `debug_config_cmd.py`, `doctor_cmd.py`, `sdk_cmd.py` and +`validate_cmd.py` all build a code the same way, and `deferred_cmd.py`'s +`cli.command-deferred` sat completely unregistered (assigned to a module +constant, never a whole literal at its `Issue(...)` call site) until the +audit this file's first run performed. + +WHAT THIS COVERS -- WIDER than the Rust pair's own two shapes, by design, and +that widening is itself the product of a remediation: an earlier version of +this file claimed parity with the Rust pair's two shapes while actually +implementing a narrower one, which left ~40% of tan's emitted codes ungated +(caught in review, not by the gate -- the exact fail-open requirement 4 below +exists to prevent, applied to this file about itself): + + 1. LITERAL sites -- `Issue("family.code", ...)`, `code="family.code"` + anywhere, and `Issue(NAME, ...)` where `NAME` is a module-level constant + assigned exactly that literal (`cli.command-deferred`'s actual shape). + 2. FULL-CODE-CARRYING CALL sites -- [`_FULL_CODE_CALLABLES`]: the port's + DOMINANT emit idiom is not `Issue("family.code", ...)` directly but a + per-command error TYPE (`BuildError`, `InitError`, `GenerateError`, ...) + or a small local wrapper (`_issue`, `fail_sdk`, `_refuse`, `_error`, ...) + constructed with the WHOLE literal code, later re-emitted through + `Issue(err.code, ...)` several frames away. Shape (1) structurally + cannot see the literal, because it never appears as `Issue(...)`'s own + argument -- it appears at the CONSTRUCTOR call. Declared per `(file, + callable name)` -> the positional index of the code argument, every call + site scanned, exactly like shape (3) below scans a prefixing helper's + call sites; a non-literal argument there is either a declared forward + ([`_KNOWN_CODE_FORWARDS`], e.g. `except BuildError as err: ... + Issue(err.code, ...)`, whose literal is captured at `err`'s OWN + construction site) or reported UNRESOLVED, never silently dropped. + 3. PREFIXED / FAMILY sites -- an f-string whose ENTIRE value is one literal + segment ending or starting with `.` plus exactly one substitution, in + EITHER order: `f"bootstrap.{code}"` (fixed prefix, substituted suffix) + or `f"{subcommand}.failed"` (substituted family, fixed suffix -- + `west_forward_cmd.py`'s mirrored shape). Auto-DISCOVERED across the + whole `tan/` tree (unlike the Rust list, which is hand-declared per + file) and then RESOLVED by scanning every call site of the one helper + function/constructor the f-string's substitution comes from, mirroring + `PREFIXING_SITES`'s "declared opener, scanned call sites, pinned count" + shape one level more automatically. + +Auto-discovery is the deliberate improvement over the Rust design for shape +(3): the Rust gate can only see a prefixing helper someone already added a +row for, so a FOURTH helper appearing elsewhere in `crates/` would escape +both of its own tests silently. Here, [`_prefix_templates`] finds every +"one fixed literal segment + one substitution" f-string in the tree by its +AST SHAPE, not by a hand-maintained file list, and +[`test_every_prefix_template_is_classified`] fails the moment one appears +that nothing below has classified -- so a fifth helper cannot hide the way a +fourth Rust one could. Shape (2) is declared rather than auto-discovered +(a bare `SomeClass("family.code", ...)` call has no AST feature that +distinguishes "this constructs a code-carrying error" from "this constructs +an unrelated value" the way a `f"prefix.{x}"` shape does), so it carries the +same non-vacuity discipline shape (3) does at the registration/test level +instead: [`test_every_emitted_issue_code_is_registered`]'s own count +(`len(literal) > 30`) would drop sharply if a `_FULL_CODE_CALLABLES` entry +silently stopped matching, the same tripwire `expected_calls` gives shape (3). + +Classifying what auto-discovery FINDS still takes a human, in one of three +declared buckets, the same non-heuristic discipline +`crates/tan-cli/tests/contract.rs`'s own `PREFIXING_SITES` and +`DECLARED_FORWARDERS` comments insist on ("a scan that guessed which +functions prefix would either miss a new one silently or invent codes from +unrelated calls"): + + * `_RESOLVABLE_HELPERS`, keyed by `(file, enclosing qualname)` -- the + dotted name of the function/method the f-string ITSELF sits inside + (`Log.take_issues`, `_refusal`, `validate.fail`), tracked by + [`_prefix_templates`] while it walks. DELIBERATELY NOT the f-string's + line number, which an earlier version of this table used and which broke + for real, not hypothetically: dev's fc88ca1 shifted `_refusal`'s own + template from :1522 to :1532 by adding lines earlier in the same file -- + changing nothing about `_refusal` itself -- and reddened this gate on an + unrelated, already-merged PR. A qualname is immune to that: it only + changes when the SITE itself is renamed, moved to a different function, + or rewritten, all of which genuinely warrant updating this table. It + still disambiguates every case a bare `(file, prefix, expr)` key could + not: `bootstrap_cmd.py` has TWO distinct templates that both substitute a + parameter literally named `code`, but one sits in `Log.take_issues` and + the other in `_refusal`, so the qualname alone tells them apart. The + substituted name is a plain parameter of the enclosing function/method + (`kind="prefix"`, the fixed literal is a PREFIX) or, mirrored, a + parameter of the function the f-string's SUBSTITUTED family comes from + while the SUFFIX is fixed (`kind="family"`, `west_forward_cmd.py`'s + `f"{subcommand}.failed"`) -- either way, every call site of that one + function is scanned, and a literal argument there IS the missing half. + Also covers a constructor whose call sites are scanned the same way even + though the substitution is not literally the enclosing function's own + parameter: `doctor_cmd.py`'s `f"doctor.{check.name}"` resolves by + scanning every `Check(...)` construction's `name` (48 call sites, all + literal -- MEASURED, not assumed, while closing tan-cli#224's own + review). A call passing something else (a `Name`, an `Attribute`, a + `Starred` unpack) is unresolved unless it also appears in + `_FORWARDER_SUFFIXES`. Two templates that share one qualname + (`west_forward_cmd.py`'s `_run_forward`, whose success and `OSError` arms + both build the identical `f"{subcommand}.failed"`) collapse to ONE + declared entry, not two -- they are the same resolvable site scanned + once, not two coincidentally-identical declarations to keep in sync by + hand. That collapse is exactly what a REVIEWER later showed was a hole, + not just an economy: because the key is `(file, qualname)`, a THIRD, + UNREGISTERED template landing inside an already-declared function is + indistinguishable from the two legitimate ones by key alone, and the old + code recorded only whether the key had been seen at all + (`seen_helper_keys: set`), not how many times. Concretely: adding a + second, shadowed-variable `f"bootstrap.{code}"` inside `_refusal` (a + comprehension `for code in (...)` shadows the parameter `_refusal` + already takes, so `ast.unparse` still reads the substituted expression as + plain `code`, and the declared literal/`kind` still match) passed every + existing assertion silently -- the only thing that caught it was + `EXPECTED_TEMPLATE_COUNT`'s own failure message, which says "bump the + count", not "this code is unregistered". Each `_RESOLVABLE_HELPERS` + entry therefore also declares `sites`: the exact number of + `_prefix_templates` matches expected at that key (`_run_forward`'s entry + reads `sites=2`, recording the "two, not one, not three" fact the old + code left implicit). [`_classify_and_resolve`] records every matched + lineno per key and calls [`_check_site_counts`], which fails, naming the + qualname, the declared vs. actual count, and every matched file:line, the + moment they diverge -- so the shadowed-comprehension shape above is now a + loud, specific failure ("_RESOLVABLE_HELPERS[...] declares sites=1 but + this run found 2") instead of a nudge to bump an unrelated scalar. + * `_FORWARDER_SUFFIXES`, keyed by `(file, exact substituted expression)` -- + the substituted expression is not a plain parameter (`refusal.code`, + `venv_refusal.code`, `result.outcome`, or a `*tuple` unpack) but its + value space was read from the real source and is small and closed (a + dataclass field fed by a handful of constructors, or an outcome derived + from two module constants). Carries the SAME `sites`-pinning discipline + the other two buckets do, for the SAME many-to-one reason, closing a + THIRD hole a reviewer found in this table specifically: the key is + `(file, expr)` -- no qualname, no call-site scope -- so a wholly + UNRELATED function whose own f-string happens to substitute an + identically-spelled expression collapses onto the same declared entry + and gets waved through by whatever suffix set that entry already + carries, without a single new call site ever being named. Measured: + adding `def _sneaky(refusal): return Issue(f"bootstrap.{refusal.code}", + "error", "smuggled")` to `bootstrap_cmd.py` and bumping + `EXPECTED_TEMPLATE_COUNT` 11 -> 12 gave "4 passed" -- every existing + assertion, silently. `sites` is the exact count of matches expected at + that key -- from [`_prefix_templates`]'s f-string scan for the three + plain-expression entries, or from [`_resolve_helper`]'s own + Starred-argument scan for the two `*tuple` entries -- asserted by the + SAME [`_check_site_counts`] the other two buckets now share. + * `_ACKNOWLEDGED_CEILINGS`, keyed by `(file, enclosing qualname)` -- the + same stable identity `_RESOLVABLE_HELPERS` keys on, for the same reason + (see its bullet above), mapping to `dict(reason=..., sites=...)` -- the + same `sites`-pinning discipline `_RESOLVABLE_HELPERS` carries, for the + same reason: an acknowledged ceiling is exactly as many-to-one a key as a + resolved helper, so a SECOND, unregistered template landing at an + already-acknowledged qualname deserves the same loud count mismatch, not + a silent "well, it's acknowledged" pass. `reason` is stated rather than + silently skipped, the same honesty the Rust gate's own "KNOWN CEILING" + paragraph practises. Held EMPTY today: `doctor_cmd.py`'s + `check.code or f"doctor.{check.name}"` ceiling this bucket used to carry + was resolved into `_RESOLVABLE_HELPERS` above once its actual cost was + measured (48 call sites, ALL literal, zero non-literal -- "a materially + bigger audit" was the ceiling's original claim, and it did not survive + contact with the real count). The bucket stays declared rather than + deleted so a FUTURE genuinely-out-of-scope template has somewhere + honest to go, per the same Rust "KNOWN CEILING" precedent -- an empty + table is not evidence no ceiling will ever be needed again. + +Shape (2) sites (`_FULL_CODE_CALLABLES`, see above) get the same declared, no +silent drop treatment through [`_KNOWN_CODE_FORWARDS`]: a code-position +argument (an `Issue(...)` first arg, any `code=` keyword, or a +`_FULL_CODE_CALLABLES` argument) that is neither a literal nor a resolved +module constant is either a declared forward -- its literal captured at the +ORIGIN this list points at, mirroring `crates/tan-cli/tests/contract.rs`'s +own `DECLARED_FORWARDERS` ("this list only says there is no literal HERE to +read, which is a fact about the call, not a licence to skip the code") -- or +reported UNRESOLVED by file:line, never silently dropped. + +Every one of these buckets is a place a REAL escape can still happen if +mis-declared -- which is exactly why +[`test_gate_rejects_a_deliberately_unregistered_code`] exists: tan-cli#275 is +the standing lesson that an assertion nobody has ever seen fail is not proven +to fire. Confirmed by hand while writing this file: with the fabricated code +below removed from the injected set the assertion goes red; restored, green +-- see that test's own body for the same check run programmatically. + +ACKNOWLEDGED CEILING -- a limit of static resolution, documented rather than +chased closed (a reviewer's second finding, tan-cli#224 review). Every bucket +above resolves a template's substituted expression by reading its TEXT +(`ast.unparse`) and matching that text against the enclosing callable's own +parameter name or a declared forward -- it does not, and structurally cannot +without reimplementing Python's own name resolution, check that the text it +read is actually BOUND the way the enclosing signature implies. A construct +that locally REBINDS the substituted name defeats that match while leaving +every surface signal this file checks unchanged. Measured, not hypothetical: +`_refusal`'s legitimate site is + + [Issue(f"bootstrap.{code}", "error", " ".join(lines))] + +where `code` is `_refusal`'s own parameter. Replacing it with + + [Issue(f"bootstrap.{code}", "error", " ".join(lines)) for code in ("sneaky-unregistered",)] + +keeps `sites=1` (still exactly one `f"bootstrap.{code}"` AST node), +keeps `kind`/literal/`expr` all matching `_RESOLVABLE_HELPERS[("tan/commands +/bootstrap_cmd.py", "_refusal")]` (`ast.unparse` reads the substituted name +as the bare identifier `code` either way -- it has no notion of WHICH `code` +a comprehension's own scope binds, only that the token spells the same), +keeps `EXPECTED_TEMPLATE_COUNT` at 11 -- and the gate stays green while +`bootstrap.sneaky-unregistered` is emitted at runtime, unregistered. + +WHAT THIS GATE DOES CATCH: every literal code, every `_FULL_CODE_CALLABLES` +site, and every prefix/family template whose substituted expression is +textually the identifier the matching declared spec says it is -- which is +every real site in this tree today (measured while writing this file: zero +unclassified, zero unresolved). WHAT IT STRUCTURALLY CANNOT: tell "this +occurrence of `code` is the enclosing function's OWN parameter" apart from +"this occurrence of `code` is a comprehension/`with`/`except`/nested-`def` +target that merely happens to share that spelling" -- that is a lexical- +scoping question, not an AST-shape one, and answering it for real means +building the same name-resolution pass CPython's own compiler does. Do NOT +add a heuristic that tries to detect local rebinding of a substituted name +-- shadowing a name is common, legitimate Python (this file's own `_scan`/ +`_walk` helpers do it), so a detector for it would either miss a subtler +shadow just as easily or flag ordinary, harmless code as suspect; either +way it is an arms race against a static-analysis limit, not a fix for one. + +THE ACTUAL BACKSTOP: this gate is a completeness REMINDER at review time, +not the last line of defence against an unregistered code reaching a +customer. The authority for what a consumer may rely on is +`contract/issue-codes.json` itself, and the release workflow's own "Bundle +the envelope contract" step (`.github/workflows/release.yml`) builds the +published `envelope-contract.json` directly FROM that registry file -- by +reading it, never by re-scanning `python/tan/` source -- so a code that +escapes this static scan is exactly as absent from the published contract as +a code nobody ever tried to register in the first place; this scan's ceiling +does not create a NEW way for that to happen, it just fails to add coverage +for one narrow shape of it. And on the wire, `alp-sdk-vscode`'s own `===` +match against a `frozen` code FAILS OPEN on anything it does not recognise +(`test_frozen_issue_codes.py`'s own docstring: "an unrecognised code is +indistinguishable from 'no problem' on the consumer side") -- so the +practical cost of this ceiling is a real problem going unsurfaced to a user +who hit it, not a crash, and the actual defence against that is a human +registering every new code in the one place (`contract/issue-codes.json`) +this gate, `test_frozen_issue_codes.py`, and the release step all read from. +""" + +from __future__ import annotations + +import ast +import json +import pathlib + +#: `contract/` lives at the repo root, one level above `python/` -- the same +#: resolution `test_frozen_issue_codes.py` uses, reused rather than +#: reinvented so there is exactly one place that path is computed. +REGISTRY = pathlib.Path(__file__).resolve().parents[3] / "contract" / "issue-codes.json" +TAN = pathlib.Path(__file__).resolve().parents[2] / "tan" + + +def _registered_codes() -> set[str]: + data = json.loads(REGISTRY.read_text(encoding="utf-8")) + codes = {e["code"] for e in data["issueCodes"]} + # Non-vacuity: an empty (or unreadable-as-expected) registry would make + # every assertion below pass by finding nothing to fail against -- + # exactly the tan-cli#275 shape this whole file exists to avoid. + assert codes, f"{REGISTRY} has no issue codes -- this gate would be vacuous" + return codes + + +def _is_code_literal(s: str) -> bool: + """Shaped like a whole `family.name` issue code: at least one dot, + otherwise lowercase/digits/dash/dot only. Deliberately narrow, the same + reason `crates/tan-cli/tests/contract.rs::emitted_code_literals` is + narrow -- so an unrelated `code="UTF-8"`-shaped kwarg or a prose string + can never be mistaken for a real code.""" + return bool(s) and "." in s and all(c.islower() or c.isdigit() or c in "-." for c in s) + + +def _is_code_suffix(s: str) -> bool: + """Shaped like the bare SUFFIX a prefixing helper takes: no dot at all + (a dot there would mean the caller already passed a whole code, which is + a literal-emit site, not a prefixed one). Two shapes accepted: the + kebab-case convention every HAND-WRITTEN suffix in this tree uses + (`board-yaml-missing`), or a bare camelCase identifier for the one + MECHANICALLY-resolved exception -- `doctor_cmd.py`'s `Check(...)` `name`s + mirror the Rust oracle's own `doctor.` convention verbatim + (`checks_to_issues`'s own docstring says so), so `boardYaml`/`zephyrSdk`/ + ... are real suffixes this scan must accept, not reject as malformed.""" + if not s or "." in s: + return False + if all(c.islower() or c.isdigit() or c == "-" for c in s): + return True + return s[0].islower() and all(c.isalnum() for c in s) + + +def _is_family_prefix(s: str) -> bool: + """Shaped like `"bootstrap."` -- lowercase/dash, exactly one trailing + dot and no other. Guards [`_prefix_templates`] against an unrelated + f-string (a path, a URL) that happens to end a literal segment in `.`.""" + return s.endswith(".") and s.count(".") == 1 and len(s) > 1 and all(c.islower() or c == "-" for c in s[:-1]) + + +def _is_family_suffix(s: str) -> bool: + """Shaped like `".failed"` -- the MIRROR of [`_is_family_prefix`]: one + leading dot, then lowercase/dash, no other dot. Guards the "substituted + FAMILY, fixed SUFFIX" template shape (`f"{subcommand}.failed"`, + `west_forward_cmd.py`) against an unrelated f-string ending a literal + segment in `.something` that is not a code suffix.""" + return s.startswith(".") and s.count(".") == 1 and len(s) > 1 and all(c.islower() or c == "-" for c in s[1:]) + + +def _parse(path: pathlib.Path) -> ast.Module: + return ast.parse(path.read_text(encoding="utf-8", errors="replace"), filename=str(path)) + + +def _rel(path: pathlib.Path) -> str: + """`tan/commands/foo.py`, the same spelling used throughout this file's + declared tables -- so a table entry can be found by grepping this file + for the exact string that would appear in a failure message. + + Falls back to `path` unchanged for a path outside `tan/`: a pytest + `tmp_path` self-test scratch file (`test_prefix_template_scan_finds_a_fresh_synthetic_site`) + is scanned by `_literal_codes_in_file`, which now calls `_rel` to key its + `_FULL_CODE_CALLABLES`/`_KNOWN_CODE_FORWARDS` lookups -- no declared table + entry can ever match a path outside `tan/`, so the exact spelling does not + matter there, but crashing on `relative_to` does.""" + try: + return str(path.relative_to(TAN.parent)).replace("\\", "/") + except ValueError: + return str(path).replace("\\", "/") + + +def _module_string_constants(tree: ast.Module) -> dict[str, str]: + """Module-level `NAME = "literal.with.a.dot"` assignments -- the + `cli.command-deferred` shape (`DEFERRED_ISSUE_CODE` in + `deferred_cmd.py`), where the whole code is named once and referenced by + identifier at the `Issue(...)` call site rather than spelled inline. + Deliberately shallow: only a direct top-level `Assign` to a `Name` + counts, so a value reassigned or computed elsewhere is correctly left + unresolved rather than guessed at.""" + consts: dict[str, str] = {} + for node in tree.body: + if ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + and _is_code_literal(node.value.value) + ): + consts[node.targets[0].id] = node.value.value + return consts + + +#: `(file, callable name)` -> the positional index of the argument that +#: carries an ALREADY-WHOLE issue code, for constructors/local helpers whose +#: declared purpose is exactly that (not a bare suffix needing a prefix -- +#: `_RESOLVABLE_HELPERS` below covers that shape). See the module docstring's +#: shape (2): the port's dominant emit idiom is a per-command error TYPE +#: (`BuildError`, `InitError`, ...) or a small local wrapper (`_issue`, +#: `fail_sdk`, `_refuse`, `_error`, `_error_outcome`, `_Notice`) constructed +#: with the whole code, later re-emitted through `Issue(err.code, ...)` -- +#: several call frames from the eventual `Issue(...)` shape (1) alone can see. +#: Every entry here was read from source (a `grep` for the class/function +#: name, then its `__init__`/signature, then every call site), the same +#: discipline `_RESOLVABLE_HELPERS`/`_FORWARDER_SUFFIXES` already apply. +_FULL_CODE_CALLABLES: dict[tuple[str, str], int] = { + ("tan/core/build_plan.py", "PlanParseError"): 0, + ("tan/commands/monitor_cmd.py", "MonitorError"): 0, + ("tan/commands/explain_cmd.py", "ExplainError"): 0, + ("tan/commands/generate_cmd.py", "GenerateError"): 0, + ("tan/commands/build/token_substitution.py", "TokenSubstitutionError"): 0, + ("tan/commands/build_cmd.py", "BuildError"): 0, + ("tan/commands/build_cmd.py", "_refuse"): 0, + ("tan/commands/build/materialise.py", "MaterialiseError"): 0, + ("tan/commands/model_cmd.py", "ModelError"): 0, + ("tan/commands/kconfig_cmd.py", "_CoreResolutionError"): 0, + ("tan/commands/init_cmd.py", "InitError"): 0, + ("tan/commands/renode_cmd.py", "_issue"): 0, + ("tan/commands/renode_cmd.py", "fail"): 0, + ("tan/commands/renode_cmd.py", "fail_sdk"): 0, + ("tan/commands/flash_cmd.py", "_error"): 1, + ("tan/commands/image_cmd.py", "_error_outcome"): 3, + ("tan/commands/image_cmd.py", "_Notice"): 0, + ("tan/commands/size_cmd.py", "_error_outcome"): 2, +} + +#: `(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, str]]: + """Every f-string used AT A CODE POSITION in `path` -- `Issue(...)`'s + first argument, or a `code=` keyword's value, the SAME two positions + [`_literal_codes_in_file`] inspects -- shaped EXACTLY `[one fixed literal + segment ending/starting with `.`, ONE substitution]`, in either order -- + e.g. `f"bootstrap.{code}"` (`kind="prefix"`) or `f"{subcommand}.failed"` + (`kind="family"`). Returns `(lineno, literal segment, unparsed + substituted expression, kind, enclosing qualname)`. + + The enclosing qualname (`Log.take_issues`, `_refusal`, `validate.fail`) + is the dotted name of the innermost function/method/class the f-string + sits inside, built while walking (the same idea Python's own + `__qualname__` encodes, minus the `` marker CPython inserts for a + nested function -- unneeded here, since nothing downstream reconstructs a + real `__qualname__`, it only looks a site up BY this identity). This is + what [`_RESOLVABLE_HELPERS`]/[`_ACKNOWLEDGED_CEILINGS`] key on INSTEAD of + `lineno`: `lineno` is still returned (and still belongs in every error + message, so a human can jump straight to the site), but it is not part of + any declared, hand-maintained key any more -- see those tables' own + comments for the concrete break (tan-cli#224, dev's fc88ca1) that made + keying on it a defect rather than a convenience. + + Scoping to code positions (rather than "any f-string in the file") is + load-bearing, not cosmetic: this tree has MANY unrelated f-strings + sharing the bare dot-suffix SHAPE -- `f"{sku}.yaml"`, `f"{tool}.exe"`, + `f"{field}.path"` -- that `_is_family_suffix` alone cannot distinguish + from a real code-assembling template (unlike `_is_family_prefix`'s + multi-char prefixes, which happen not to collide with anything else in + this tree today). `_is_family_prefix`/`_is_family_suffix` narrow the + SHAPE; scoping to code positions narrows WHERE that shape is even + looked for -- caught by measurement while closing tan-cli#224's own + review (28 false positives from an unscoped scan, none of them a real + issue-code template). + + Walks the WHOLE subtree of each code-position expression (not just its + top level), so a JoinedStr nested one level in -- `doctor_cmd.py`'s + `check.code or f"doctor.{check.name}"`, a `BoolOp` -- is still found. + """ + tree = _parse(path) + out: list[tuple[int, str, str, str, str]] = [] + stack: list[str] = [] + + def _qualname() -> str: + return ".".join(stack) if stack else "" + + def _scan(value: ast.expr | None) -> None: + if value is None: + return + qualname = _qualname() + for node in ast.walk(value): + if not isinstance(node, ast.JoinedStr) or len(node.values) != 2: + continue + head, tail = node.values + if ( + isinstance(head, ast.FormattedValue) + and isinstance(tail, ast.Constant) + and isinstance(tail.value, str) + and _is_family_suffix(tail.value) + ): + out.append((node.lineno, tail.value, ast.unparse(head.value), "family", qualname)) + continue + if ( + isinstance(head, ast.Constant) + and isinstance(head.value, str) + and _is_family_prefix(head.value) + and isinstance(tail, ast.FormattedValue) + ): + out.append((node.lineno, head.value, ast.unparse(tail.value), "prefix", qualname)) + + def _walk(node: ast.AST) -> None: + # Tracks the enclosing def/class stack (for the qualname `_scan` + # reads) while still visiting EVERY node in the tree, the same + # completeness the old flat `ast.walk(tree)` had -- a nested + # function/class pushes its name, recurses into its own body, then + # pops, so a template two scopes deep still gets the full dotted + # name. + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + stack.append(node.name) + for child in ast.iter_child_nodes(node): + _walk(child) + stack.pop() + return + if isinstance(node, ast.keyword) and node.arg == "code": + _scan(node.value) + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "Issue" and node.args: + _scan(node.args[0]) + for child in ast.iter_child_nodes(node): + _walk(child) + + _walk(tree) + return out + + +# --------------------------------------------------------------------------- +# The declared classification of every prefix template this tree contains +# today (tan-cli#224). See the module docstring for what each bucket means +# and why a fourth is deliberately not offered. +# --------------------------------------------------------------------------- + +#: The exact number of `_prefix_templates` matches across the whole `tan/` +#: tree, TODAY. Pinned exactly, the same reason +#: `crates/tan-cli/tests/contract.rs`'s `PREFIXED_CODE_COUNT` and per-file +#: `expected_sites` are pinned rather than floored: a template silently +#: disappearing (a rename that stops covering an emit) is exactly as real a +#: defect as a new one silently appearing uncovered, and only an EXACT count +#: notices the first case. Deliberately kept as a scalar count, not replaced: +#: it is ALREADY immune to the line-shift defect this file's re-keying fixes +#: (tan-cli#224) -- it counts template OCCURRENCES across the tree, never a +#: line number, so an unrelated edit that merely moves existing sites cannot +#: change it. It still needs a hand bump on a genuine content change (a +#: template added or removed), which is the correct, narrow cost a drift +#: detector for "did the template COUNT change" should have -- the defect +#: fixed here was the SEPARATE `_RESOLVABLE_HELPERS`/`_ACKNOWLEDGED_CEILINGS` +#: keys pinning WHERE each one lives, not this total. +#: +#: KEPT alongside the per-key `sites` field ALL THREE declared tables now +#: carry (`_RESOLVABLE_HELPERS`/`_ACKNOWLEDGED_CEILINGS` from the first +#: review round, `_FORWARDER_SUFFIXES` from the second, tan-cli#224 review +#: remediation), deliberately not replaced by either: the two are NOT +#: redundant, because they cover different ground. `sites` only exists at +#: keys someone has DECLARED in one of the three tables -- of today's 11 +#: templates, all 11 now sit behind a `sites` pin, the last 3 +#: (`bootstrap_cmd.py`'s `refusal.code` forward, `doctor_cmd.py`'s +#: `venv_refusal.code` forward, `validate_cmd.py`'s `result.outcome` forward) +#: via `_FORWARDER_SUFFIXES`'s OWN `sites` field once a reviewer showed the +#: identical many-to-one collapse reaches a `(file, expr)` key exactly as +#: easily as a `(file, qualname)` one -- but a BRAND NEW expression or +#: qualname nobody has declared ANYWHERE still only shows up as a total +#: mismatch here, never as a per-key one (there is no key yet for it to +#: collapse onto). Conversely, `sites` catches something this scalar cannot +#: localize on its own: which SPECIFIC key absorbed an extra template, by +#: name, with every offending file:line -- this total only says "11 became +#: 12" and, on its own, invites exactly the "bump the number and move on" +#: response the reviewer's exploit relied on. One coarse, whole-tree tripwire +#: plus precise per-key tripwires is not the same overlap as two detectors +#: both hand-bumped for the SAME fact; dropping either narrows real coverage. +EXPECTED_TEMPLATE_COUNT = 11 + +#: `(file, enclosing qualname)` -> how to recover the missing half, for every +#: template resolvable by scanning one declared callable's call sites. +#: `enclosing qualname` is the dotted name of the function/method the +#: f-string ITSELF sits inside (`Log.take_issues`, `_refusal`, +#: `validate.fail`), tracked by [`_prefix_templates`] while it walks -- +#: DELIBERATELY NOT the f-string's line number, which an earlier version of +#: this table used and which broke for real (tan-cli#224): dev's fc88ca1 +#: shifted `_refusal`'s own template from :1522 to :1532 by adding lines +#: earlier in the same file, changing nothing about `_refusal` itself, and +#: reddened this gate on an unrelated, already-merged PR. A qualname is +#: immune to that -- it only changes if the SITE itself is renamed, moved to +#: a different function, or rewritten, all of which genuinely warrant +#: updating this table -- and it still disambiguates every case a bare +#: `(file, prefix, expr)` key could not: `bootstrap_cmd.py` has TWO distinct +#: templates that both substitute a parameter literally named `code`, but +#: one sits in `Log.take_issues` and the other in `_refusal`. `kind` +#: (default `"prefix"`) picks which half is fixed: `"prefix"` -- `prefix` is +#: the fixed literal, `expr`'s call-site argument is the SUFFIX +#: (`f"bootstrap.{code}"`, `bootstrap.` + scanned code); `"family"` -- +#: `suffix` is the fixed literal, `expr`'s call-site argument is the FAMILY +#: (`f"{subcommand}.failed"`, scanned subcommand + `.failed`). Either way the +#: named callable's call sites are scanned the same way (`name`/`attr`, +#: `arg_index`/`arg_keyword`) -- a constructor whose call sites carry the +#: missing half works exactly like a helper whose OWN parameter does +#: (`doctor_cmd.py`'s `Check(name=...)` below is a constructor, not the +#: f-string's enclosing function; `_resolve_helper` does not care which). +#: Two templates that share one qualname (`west_forward_cmd.py`'s +#: `_run_forward`, whose success and `OSError` arms both build the identical +#: `f"{subcommand}.failed"`) collapse to ONE entry below, not two -- they are +#: the same resolvable site scanned once, never two coincidentally-identical +#: declarations to keep in sync by hand. +#: Every entry's `sites` field is the exact number of `_prefix_templates` +#: MATCHES (f-string occurrences) expected at that `(file, qualname)` key -- +#: a SEPARATE axis from `expected_calls` (the number of calls to the scanned +#: helper/constructor itself, e.g. `Log.warn(...)` call sites). This is the +#: closing of the hole a reviewer found in the qualname re-keying (tan-cli +#: #224): because the key is `(file, qualname)`, not `(file, lineno)`, a +#: SECOND, unregistered template appearing inside an already-declared +#: function/method collapses onto the SAME key -- the old code only recorded +#: key MEMBERSHIP (`seen_helper_keys: set`), which a second occurrence at an +#: already-seen key does not change, so it passed silently as long as its +#: `kind`/literal/`expr` happened to match the declared spec (exactly the +#: shape a shadowed comprehension variable produces). `sites` makes the COUNT +#: itself a declared, asserted fact -- `_classify_and_resolve` records every +#: lineno matched per key and hands it to [`_check_site_counts`], which fails, +#: naming the qualname, both counts, and every matched file:line, when the +#: real count differs from `sites`. Bump +#: `sites` ONLY after confirming each newly-listed line is a legitimate, +#: already-registered emit -- the same discipline `expected_calls`'s own +#: docstring insists on for `_resolve_helper`'s call-site count. +_RESOLVABLE_HELPERS: dict[tuple[str, str], dict] = { + ("tan/commands/bootstrap_cmd.py", "Log.take_issues"): dict( + # `Log.warn(self, code, message)`, drained by `take_issues` into + # this exact f-string -- every call site is `.warn(...)`. + prefix="bootstrap.", + expr="code", + attr="warn", + arg_index=0, + # 17, not 16, since dev's 518ac8c (tan-cli#334) split the single + # `zephyr-base-incompatible` warn into a found/else pair so the message + # can name the evidence. Two call sites, ONE code, already registered + # (`bootstrap.zephyr-base-incompatible`) -- checked before bumping, + # which is the whole point of the count: it forced the look. + expected_calls=17, + sites=1, + ), + ("tan/commands/bootstrap_cmd.py", "_refusal"): dict( + prefix="bootstrap.", + expr="code", + name="_refusal", + arg_index=1, + expected_calls=8, + sites=1, + ), + ("tan/commands/debug_config_cmd.py", "_failure"): dict( + prefix="debug-config.", + expr="code", + name="_failure", + arg_keyword="code", + expected_calls=2, + sites=1, + ), + ("tan/commands/sdk_cmd.py", "_fail"): dict( + prefix="sdk.", + expr="code", + name="_fail", + arg_keyword="code", + expected_calls=5, + sites=1, + ), + ("tan/commands/validate_cmd.py", "validate.fail"): dict( + prefix="validate.", + expr="code", + name="fail", + arg_index=0, + expected_calls=5, + sites=1, + ), + ("tan/commands/doctor_cmd.py", "checks_to_issues"): dict( + # `check.code or f"doctor.{check.name}"` in `checks_to_issues()` -- + # the ceiling this bucket used to acknowledge instead of resolving + # (tan-cli#224 review): MEASURED at 48 `Check(...)` constructions, + # every one passing its `name` positionally and literally, zero + # non-literal, 20 distinct camelCase names (`_is_code_suffix` admits + # camelCase for exactly this reason -- see its own docstring). + prefix="doctor.", + expr="check.name", + name="Check", + arg_index=0, + expected_calls=48, + sites=1, + ), + ("tan/commands/west_forward_cmd.py", "_run_forward"): dict( + # `Issue(f"{subcommand}.failed", ...)` -- the MIRRORED shape + # (tan-cli#224 review): `subcommand` is `_run_forward`'s own + # parameter, closed to the three literal strings its three Typer + # callers (`migrate`/`lock`/`quality`) pass. TWO templates in this + # one function (the success arm and the `OSError` arm) share this + # exact spec and collapse to one declared entry here -- `sites=2` + # is what now RECORDS that fact instead of leaving it to a comment: + # before this field existed, nothing distinguished "one function, + # two known templates" from "one function, one known template plus + # a silently-collapsed unregistered one" -- see this table's own + # comment above. + kind="family", + suffix=".failed", + expr="subcommand", + name="_run_forward", + arg_index=0, + expected_calls=3, + sites=2, + ), +} + +#: `(file, exact substituted expression text)` -> `dict(suffixes=..., sites=...)` +#: -- `suffixes` is the closed, source-verified set of suffixes a FORWARDED +#: expression can carry, read from the origin, not guessed (see the module +#: docstring's bucket description). `warn(*skew)`/`warn(*ceiling)` are keyed +#: by the call shape rather than the bare unparsed name, because a `Starred` +#: argument is not a template substitution at all -- it is resolved per CALL +#: SITE inside `_resolve_helper`, not per f-string. +#: +#: `sites` carries the SAME discipline `_RESOLVABLE_HELPERS`/ +#: `_ACKNOWLEDGED_CEILINGS` do, for a REVIEWER-found reason specific to this +#: table: the key is `(file, expr)` -- no qualname, no enclosing-scope +#: information at all -- so a wholly UNRELATED function elsewhere in the same +#: file whose own f-string happens to substitute an identically-spelled +#: expression collapses onto the same entry and is silently resolved by +#: whatever suffix set that entry already declares. Measured: adding +#: `def _sneaky(refusal): return Issue(f"bootstrap.{refusal.code}", "error", +#: "smuggled")` to `bootstrap_cmd.py` and bumping `EXPECTED_TEMPLATE_COUNT` +#: 11 -> 12 gave "4 passed" before this field existed. For the three plain- +#: expression entries, `sites` is the exact count of [`_prefix_templates`] +#: matches at that `(file, expr)` key (asserted by [`_check_site_counts`], +#: the same function the other two tables share); for the two `*tuple` +#: entries, it is the exact count of Starred-argument call sites +#: [`_resolve_helper`] itself matches to that key -- a DIFFERENT scan +#: (`_resolve_helper`'s own `expected_calls` loop over calls to the ONE +#: helper each entry's `warn(*...)` shape names), fed into the SAME +#: `_check_site_counts` check by `_classify_and_resolve` merging both scans' +#: hits before calling it. +_FORWARDER_SUFFIXES: dict[tuple[str, str], dict] = { + # `Issue(f"bootstrap.{refusal.code}", ...)` in bootstrap_cmd.py -- no line + # number on purpose: this whole table was re-keyed off line numbers because + # they rot (tan-cli#224), and a comment that pins one rots the same way. + # forwards `check_prerequisites()`'s `PrereqFailure.code` + # (`tan/core/bootstrap.py`), which is exactly one of these four literals + # depending on which refusal branch it returned. + ("tan/commands/bootstrap_cmd.py", "refusal.code"): dict( + suffixes=frozenset({"prerequisites-missing", "python-not-runnable", "python-too-old", "venv-unusable"}), + sites=1, + ), + # `code=f"bootstrap.{venv_refusal.code}"` at doctor_cmd.py:662 forwards + # the SAME `PrereqFailure`, but `venv_refusal` there is only ever set + # from `posix_venv_unusable()` (doctor_cmd.py:2342) -- a strictly + # narrower value space than the bootstrap_cmd.py forward above. + ("tan/commands/doctor_cmd.py", "venv_refusal.code"): dict(suffixes=frozenset({"venv-unusable"}), sites=1), + # `Issue(f"validate.{result.outcome}", ...)` at validate_cmd.py:546 only + # ever fires inside `for message in result.messages`, and + # `outcome = OUTCOME_CLEAN if not messages else OUTCOME_SCHEMA_VIOLATION` + # (validate_cmd.py:272) means a non-empty `messages` implies + # `outcome == OUTCOME_SCHEMA_VIOLATION == "schema-violation"` always. + ("tan/commands/validate_cmd.py", "result.outcome"): dict(suffixes=frozenset({"schema-violation"}), sites=1), + # `log.warn(*skew)` / `log.warn(*ceiling)` in bootstrap_cmd.py (no line + # numbers -- see the note on the entry above) + # unpack the `(suffix, message)` pairs `python_floor_skew_warning()` and + # `python_ceiling_warning()` (`tan/core/bootstrap.py`) return. + ("tan/commands/bootstrap_cmd.py", "warn(*skew)"): dict(suffixes=frozenset({"python-floor-skew"}), sites=1), + ("tan/commands/bootstrap_cmd.py", "warn(*ceiling)"): dict( + suffixes=frozenset({"python-newer-than-verified"}), sites=1 + ), +} + +#: `(file, enclosing qualname)` -- the same stable identity +#: `_RESOLVABLE_HELPERS` keys on, for the same reason (see its own comment +#: above: a line number moves on any unrelated edit, tan-cli#224) -- -> a +#: `dict(reason=..., sites=...)`, the same `sites`-pinning discipline +#: `_RESOLVABLE_HELPERS` carries (see its own leading comment): `reason` is +#: why this template is deliberately NOT resolved, stated rather than +#: silently absent (matching `contract.rs`'s own "KNOWN CEILING" paragraph); +#: `sites` is the exact number of `_prefix_templates` matches acknowledged at +#: that key, so a SECOND, unregistered template arriving at an already- +#: acknowledged qualname is a named count mismatch, not a silent collapse -- +#: the same hole closed in `_RESOLVABLE_HELPERS`, applied here too since nothing +#: about "this key is an acknowledged ceiling instead of a resolved helper" +#: makes it immune to the same many-to-one key risk. +#: See the module docstring's third bucket. Held EMPTY today: `doctor_cmd +#: .py`'s `check.code or f"doctor.{check.name}"` was the sole entry until +#: tan-cli#224's own review measured its actual cost (48 `Check(...)` call +#: sites, ALL literal) and found it resolvable, not a real ceiling -- it +#: moved to `_RESOLVABLE_HELPERS` above. The bucket stays declared, not +#: deleted, so a genuinely out-of-scope FUTURE template has somewhere honest +#: to go rather than forcing a false resolution. +_ACKNOWLEDGED_CEILINGS: dict[tuple[str, str], dict] = {} + + +def _calls_matching(tree: ast.Module, *, attr: str | None = None, name: str | None = None) -> list[ast.Call]: + """Every `ast.Call` whose callee is `.attr(` (when `attr` is + given) or a bare `name(` (when `name` is given).""" + out: list[ast.Call] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if attr is not None and isinstance(func, ast.Attribute) and func.attr == attr: + out.append(node) + elif name is not None and isinstance(func, ast.Name) and func.id == name: + out.append(node) + return out + + +def _resolve_helper( + path: pathlib.Path, + *, + kind: str = "prefix", + prefix: str | None = None, + suffix: str | None = None, + attr: str | None = None, + name: str | None = None, + arg_index: int | None = None, + arg_keyword: str | None = None, + expected_calls: int, +) -> tuple[set[str], list[str], dict[tuple[str, str], list[int]]]: + """Scan every call to the declared helper in `path`, read the code + argument at `arg_index` (positional) or `arg_keyword`, and return + `(reconstructed codes, unresolved-descriptions, forwarder-hit linenos)`. + `kind="prefix"` (default) reconstructs `prefix + `; + `kind="family"` reconstructs ` + suffix` + (`west_forward_cmd.py`'s mirrored shape). + + `expected_calls` is asserted EXACTLY, mirroring `PREFIXING_SITES`'s own + pinned per-file counts (`contract.rs:663-682`) for the identical reason: + a floor lets a call site disappear unnoticed as long as enough others + remain to clear it. + + 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. 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) + tree = _parse(path) + calls = _calls_matching(tree, attr=attr, name=name) + assert len(calls) == expected_calls, ( + f"{rel}: expected {expected_calls} call(s) to {opener}(...), found " + f"{len(calls)} -- a prefixing call site was ADDED (register its code, " + f"then bump this count) or REMOVED (a rename silently stopped this gate " + f"covering an emit). See _resolve_helper's docstring." + ) + parts: set[str] = set() + unresolved: list[str] = [] + forwarder_hits: dict[tuple[str, str], list[int]] = {} + for call in calls: + arg: ast.expr | None + if arg_keyword is not None: + arg = next((kw.value for kw in call.keywords if kw.arg == arg_keyword), None) + elif arg_index is not None and len(call.args) > arg_index: + arg = call.args[arg_index] + else: + arg = None + + if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and _is_code_suffix(arg.value): + parts.add(arg.value) + continue + if isinstance(arg, ast.Starred): + key = (rel, f"{opener}(*{ast.unparse(arg.value)})") + declared = _FORWARDER_SUFFIXES.get(key) + if declared is None: + unresolved.append( + f"{rel}:{call.lineno} -- `{opener}(*{ast.unparse(arg.value)})` unpacks a " + f"tuple this scan cannot read a suffix from directly. Add {key!r} to " + f"_FORWARDER_SUFFIXES with the known suffix set, read from source." + ) + else: + parts |= declared["suffixes"] + forwarder_hits.setdefault(key, []).append(call.lineno) + continue + got = ast.unparse(arg) if arg is not None else "no matching argument" + unresolved.append( + f"{rel}:{call.lineno} -- `{opener}(...)`'s code argument is not a literal " + f"({got}). Either pass a literal suffix, or if it forwards a code from " + f"elsewhere, resolve the value space by hand and add it to " + f"_FORWARDER_SUFFIXES." + ) + if kind == "prefix": + assert prefix is not None + return {prefix + s for s in parts}, unresolved, forwarder_hits + assert suffix is not None + return {s + suffix for s in parts}, unresolved, forwarder_hits + + +def _check_site_counts( + declared: dict[tuple[str, str], dict], seen: dict[tuple[str, str], list[int]], bucket: str +) -> list[str]: + """The count check the qualname/expr re-keying gap needs, shared by all + THREE many-to-one declared tables (`_RESOLVABLE_HELPERS`, + `_ACKNOWLEDGED_CEILINGS`, `_FORWARDER_SUFFIXES`): `declared` maps a + `(file, identity)` key -- `identity` is an enclosing qualname for the + first two tables, a substituted expression for the third -- to a spec + carrying a `sites` int, the exact number of matches that key is declared + to cover. `seen` is what THIS RUN actually found there, keyed the same + way (from [`_prefix_templates`] for the qualname-keyed tables and the + plain-expression `_FORWARDER_SUFFIXES` entries; from + [`_resolve_helper`]'s own Starred-argument scan for the `*tuple` + `_FORWARDER_SUFFIXES` entries -- see that function's docstring). Any + mismatch, in EITHER direction, is returned as a message naming the key, + both counts, and every matched file:line -- never silently absorbed the + way a bare membership check (`key in declared`) would. + + Deliberately extracted to a MODULE-LEVEL function, not left as a closure + inside `_classify_and_resolve` (tan-cli#224 review, MAJOR finding): a + closure captures its enclosing scope and cannot be called with synthetic + input by a test, so the only thing that had ever watched this exact + assertion fire was a hand-edit of the production tree -- which does not + survive into CI, precisely the tan-cli#275 lesson this file cites about + itself elsewhere. Returns a list of messages rather than raising or + mutating a list captured from the caller, so a test can call this + directly and assert on the return value -- see + `test_check_site_counts_flags_a_declared_vs_actual_mismatch`, which does + exactly that for both the resolved-helper shape and the (empty-in- + production, doubly unproven without this) acknowledged-ceiling shape. + """ + messages: list[str] = [] + for key, spec in declared.items(): + rel, identity = key + expected = spec["sites"] + lines = sorted(seen.get(key, [])) + if len(lines) == expected: + continue + where = ", ".join(f"{rel}:{ln}" for ln in lines) or "none" + if len(lines) > expected: + what_to_do = ( + "a NEW, unregistered site landed at this same declared key instead of " + "being caught -- read each line above, confirm which is genuinely new, " + "register its code (add it to contract/issue-codes.json, the same as any " + "other emit site), and only then bump `sites` to match." + ) + else: + what_to_do = ( + "a declared site disappeared -- it was renamed, moved to a different " + "function, or removed; update or delete this entry (and " + "EXPECTED_TEMPLATE_COUNT if the total template count changed, not just " + "this key's share of it)." + ) + messages.append( + f"{bucket}[{key!r}] (at {identity}) declares sites={expected} but this run " + f"found {len(lines)} at {rel}: {where} -- {what_to_do}" + ) + return messages + + +def _classify_and_resolve( + templates: dict[str, list[tuple[int, str, str, str, str]]], +) -> tuple[set[str], list[str], list[str]]: + """Walk every discovered template, classify it into one of the three + declared buckets, and resolve the ones that are classified. Returns + `(reconstructed codes, unresolved call sites, unclassified templates)`. + """ + codes: set[str] = set() + unresolved: list[str] = [] + unclassified: list[str] = [] + # `(file, qualname)` -> every `_prefix_templates` lineno matched at that + # key, for _RESOLVABLE_HELPERS and _ACKNOWLEDGED_CEILINGS respectively. + # DELIBERATELY not a `set` of keys seen (the old `seen_helper_keys`): the + # key is `(file, qualname)`, a many-to-one mapping (every template inside + # one function shares it), so recording only MEMBERSHIP cannot notice a + # SECOND, unregistered template landing on an already-declared key -- the + # exact hole a reviewer found in the qualname re-keying (tan-cli#224): a + # shadowed comprehension variable inside `_refusal` produces a second + # `f"bootstrap.{code}"` whose kind/literal/expr all match the existing + # declaration, so it would pass the asserts below unnoticed. Recording + # every lineno lets the per-key count check further down compare the + # REAL count against each entry's declared `sites` and name the exact + # new (or missing) line. + seen_helper_lines: dict[tuple[str, str], list[int]] = {} + seen_ceiling_lines: dict[tuple[str, str], list[int]] = {} + # `(file, expr)` -> every lineno matched at that _FORWARDER_SUFFIXES key, + # from BOTH sources that can match one: a plain-expression template found + # right here in this loop, or (merged in below, after the + # `_RESOLVABLE_HELPERS` loop runs) a Starred call site `_resolve_helper` + # itself matches. Same many-to-one reasoning as the two dicts above, for + # the reviewer-found reason `_FORWARDER_SUFFIXES`'s own leading comment + # gives: this key has no qualname or call-site scope at all, so it is + # if anything an EASIER key for an unrelated site to collapse onto. + seen_forwarder_lines: dict[tuple[str, str], list[int]] = {} + + for rel, sites in templates.items(): + for lineno, literal, expr, kind, qualname in sites: + # `key` is `(file, enclosing qualname)` -- NOT `(file, lineno)` -- + # so an unrelated edit that merely shifts this site's line cannot + # desync it from its declaration; see _RESOLVABLE_HELPERS' own + # comment (tan-cli#224). `lineno` still rides along on every + # message below, purely so a human can jump straight to the site. + key = (rel, qualname) + if key in _ACKNOWLEDGED_CEILINGS: + seen_ceiling_lines.setdefault(key, []).append(lineno) + continue + if key in _RESOLVABLE_HELPERS: + spec = _RESOLVABLE_HELPERS[key] + spec_kind = spec.get("kind", "prefix") + assert spec_kind == kind, ( + f"{rel}:{lineno} (in {qualname}) -- _RESOLVABLE_HELPERS declared " + f"kind={spec_kind!r} but the template now reads kind={kind!r}; the " + f"f-string changed shape -- update the declaration." + ) + declared_literal = spec["prefix"] if kind == "prefix" else spec["suffix"] + assert declared_literal == literal and spec["expr"] == expr, ( + f"{rel}:{lineno} (in {qualname}) -- _RESOLVABLE_HELPERS declared " + f"literal={declared_literal!r} expr={spec['expr']!r} but the template now " + f"reads literal={literal!r} expr={expr!r}; the f-string changed shape -- " + f"update the declaration." + ) + seen_helper_lines.setdefault(key, []).append(lineno) + continue + fwd_key = (rel, expr) + fwd = _FORWARDER_SUFFIXES.get(fwd_key) + if fwd is not None: + seen_forwarder_lines.setdefault(fwd_key, []).append(lineno) + suffixes = fwd["suffixes"] + codes |= {literal + s for s in suffixes} if kind == "prefix" else {s + literal for s in suffixes} + continue + shape = f'f"{literal}{{{expr}}}"' if kind == "prefix" else f'f"{{{expr}}}{literal}"' + unclassified.append( + f"{rel}:{lineno} (in {qualname}) -- new prefix template {shape} is not in " + f"_RESOLVABLE_HELPERS, _FORWARDER_SUFFIXES or _ACKNOWLEDGED_CEILINGS. " + f"Classify it in one of the three (see this file's module docstring)." + ) + + for key, spec in _RESOLVABLE_HELPERS.items(): + rel = key[0] + site_codes, site_unresolved, site_forwarder_hits = _resolve_helper( + TAN.parent / rel, + kind=spec.get("kind", "prefix"), + prefix=spec.get("prefix"), + suffix=spec.get("suffix"), + attr=spec.get("attr"), + name=spec.get("name"), + arg_index=spec.get("arg_index"), + arg_keyword=spec.get("arg_keyword"), + expected_calls=spec["expected_calls"], + ) + codes |= site_codes + unresolved.extend(site_unresolved) + for fwd_key, lines in site_forwarder_hits.items(): + seen_forwarder_lines.setdefault(fwd_key, []).extend(lines) + + unclassified.extend(_check_site_counts(_RESOLVABLE_HELPERS, seen_helper_lines, "_RESOLVABLE_HELPERS")) + unclassified.extend(_check_site_counts(_ACKNOWLEDGED_CEILINGS, seen_ceiling_lines, "_ACKNOWLEDGED_CEILINGS")) + unclassified.extend(_check_site_counts(_FORWARDER_SUFFIXES, seen_forwarder_lines, "_FORWARDER_SUFFIXES")) + + return codes, unresolved, unclassified + + +def _all_prefix_templates() -> dict[str, list[tuple[int, str, str, str, str]]]: + return {_rel(path): _prefix_templates(path) for path in sorted(TAN.rglob("*.py"))} + + +def _all_literal_codes() -> tuple[dict[str, list[str]], list[str]]: + """`(code -> the files it was found in, every unresolved code-position + site across the whole tree)` -- never silently dropped, see + `_literal_codes_in_file`.""" + found: dict[str, list[str]] = {} + unresolved: list[str] = [] + for path in sorted(TAN.rglob("*.py")): + codes, site_unresolved = _literal_codes_in_file(path) + for code in codes: + found.setdefault(code, []).append(_rel(path)) + unresolved.extend(site_unresolved) + return found, unresolved + + +def _missing(emitted: set[str], registered: set[str]) -> list[str]: + """The pure diff both the real gate and its self-test below share, so the + self-test exercises the SAME comparison the real assertion makes rather + than a reimplementation of it.""" + return sorted(emitted - registered) + + +def test_every_prefix_template_is_classified(): + """Non-vacuity + drift pin for auto-discovery itself (tan-cli#224): the + template COUNT is exact, and every template found must resolve to one of + the three declared buckets. A template appearing with none of the three + is the exact hole this file exists to close for a FUTURE prefixing + helper, the same way #224 (and its own review remediation) closed it for + the ones that already existed. + """ + templates = _all_prefix_templates() + total = sum(len(sites) for sites in templates.values()) + found_lines = "\n".join( + ( + f' {rel}:{lineno} (in {qualname}) f"{literal}{{{expr}}}"' + if kind == "prefix" + else f' {rel}:{lineno} (in {qualname}) f"{{{expr}}}{literal}"' + ) + for rel, sites in templates.items() + for lineno, literal, expr, kind, qualname in sites + ) + assert total == EXPECTED_TEMPLATE_COUNT, ( + f'found {total} f-string prefix templates (`f"family.{{code}}"` shape) ' + f"across tan/, expected {EXPECTED_TEMPLATE_COUNT}. Fewer means one was " + f"rewritten (update the count AND check whether a _RESOLVABLE_HELPERS / " + f"_FORWARDER_SUFFIXES / _ACKNOWLEDGED_CEILINGS entry is now stale); more " + f"means a NEW prefixing helper appeared -- classify it in one of the " + f"three buckets (see this file's module docstring), then bump this " + f"count. Found:\n{found_lines}" + ) + _, _, unclassified = _classify_and_resolve(templates) + assert not unclassified, "Unclassified prefix template(s):\n " + "\n ".join(unclassified) + + +def test_every_emitted_issue_code_is_registered(): + """The pair `crates/tan-cli/tests/contract.rs::every_emitted_issue_code_is_registered` + + `::every_prefixed_issue_code_is_registered` ported to the surface that + actually ships (tan-cli#224) -- see the module docstring for the full + design. LITERAL codes and PREFIXED codes are both required to appear in + `contract/issue-codes.json` at some status. + """ + registered = _registered_codes() + + literal, literal_unresolved = _all_literal_codes() + # Never a silent drop (tan-cli#224's own review finding): a code-position + # argument this scan cannot resolve to a literal, and that is not a + # declared forward, is reported by file:line -- not quietly absent from + # `literal` with no trace. + assert not literal_unresolved, ( + f"{len(literal_unresolved)} code-position argument(s) could not be resolved to a " + "literal and are not declared in _KNOWN_CODE_FORWARDS or _FULL_CODE_CALLABLES:\n " + + "\n ".join(literal_unresolved) + ) + # Non-vacuity: a scanner that silently matched nothing would pass this + # gate while checking nothing at all -- the tan-cli#219 failure mode. + assert len(literal) > 30, ( + f"found only {len(literal)} literal issue codes across tan/ -- the scan is " + f"broken, and a broken scan makes this gate vacuous" + ) + + templates = _all_prefix_templates() + prefixed, unresolved, unclassified = _classify_and_resolve(templates) + assert not unclassified, ( + "Unclassified prefix template(s) -- see test_every_prefix_template_is_classified:\n " + + "\n ".join(unclassified) + ) + assert not unresolved, ( + f"{len(unresolved)} prefixed emit site(s) could not be resolved:\n " + "\n ".join(unresolved) + ) + + emitted = set(literal) | prefixed + missing = _missing(emitted, registered) + assert not missing, ( + f"{len(missing)} issue code(s) are emitted by python/tan but appear in " + "contract/issue-codes.json at NO status:\n" + + "\n".join( + f" {c} (sites: {', '.join(literal.get(c, ['assembled by a prefixing helper']))})" for c in missing + ) + + "\n\nAn unregistered code is ungated on both sides of the seam at once: this " + "repo's registry-driven checks never see it, and the published " + "envelope-contract.json is built from that same registry, so alp-sdk-vscode " + 'cannot see it either. Add each one with "status": "reserved" and ' + '"consumer": "none" -- that costs nothing, since a reserved code may ' + 'still be renamed freely. Use "frozen" ONLY once a consumer actually binds ' + "to it." + ) + + +def test_gate_rejects_a_deliberately_unregistered_code(): + """tan-cli#275's own lesson, applied to this gate specifically: an + assertion nobody has ever watched fail is not proven to fire. This test + is that watch -- it exercises the SAME `_missing` comparison + `test_every_emitted_issue_code_is_registered` makes, against the REAL + registry, with one fabricated code injected into the emitted set. + + Manually verified both ways while writing this file (not just asserted + here): with the fabricated code removed from the injected set the + assertion below fails (`AssertionError`), and restored it passes -- so + this genuinely exercises the failure path, not a tautology that can + never go red. + """ + registered = _registered_codes() + real_emitted = set(_all_literal_codes()[0]) + # Not a real code -- guaranteed absent from the registry both by + # construction (this spelling names itself as one) and because a real + # code always has a plausible family; "zzz-tan-cli-224-self-test" is + # neither. + fabricated = "zzz-tan-cli-224-self-test.never-registered" + assert fabricated not in registered, "the fabricated self-test code collided with a real one -- pick another" + + injected = real_emitted | {fabricated} + offenders = _missing(injected, registered) + assert fabricated in offenders, ( + "the gate's own diff did not flag a deliberately unregistered code -- " + "this gate cannot fail, which per tan-cli#275 means it is not a gate" + ) + + # And the negative: with nothing injected, that same fabricated spelling + # must NOT appear -- proving the assertion is sensitive to the input, + # not unconditionally red. + offenders_clean = _missing(real_emitted, registered) + assert fabricated not in offenders_clean + + +def test_check_site_counts_flags_a_declared_vs_actual_mismatch(): + """tan-cli#224 review, MAJOR finding: `_check_site_counts` used to be a + closure inside `_classify_and_resolve`, so nothing could exercise it + directly -- the only thing that had ever watched its assertion fire was a + hand-edit of the production tree, which per tan-cli#275 (this file's own + standing lesson, applied to itself) does not count as proof an assertion + fires. Extracting it to a top-level function (see its own docstring) + makes it directly callable with synthetic input, the same self-test + discipline `test_gate_rejects_a_deliberately_unregistered_code` and + `test_prefix_template_scan_finds_a_fresh_synthetic_site` already apply to + their own targets. + + Exercises BOTH halves `_check_site_counts` is used for. `_RESOLVABLE_HELPERS`' + shape first, with a synthetic `declared` spec claiming `sites=1` against a + synthetic `seen` map recording 2 linenos at that key -- mirroring, at the + unit level, the exact shadowed-comprehension exploit `sites` was added to + catch (two templates silently sharing one declared key). Then + `_ACKNOWLEDGED_CEILINGS`'s shape, doubly unproven before this test: that + table is EMPTY in production today, so nothing had ever driven a real + value through this exact code path for that bucket at all, synthetic or + otherwise. + """ + key = ("tan/commands/selftest_cmd.py", "_selftest_helper") + declared = {key: dict(prefix="selftest.", expr="code", sites=1)} + seen_mismatched = {key: [10, 20]} + + messages = _check_site_counts(declared, seen_mismatched, "_RESOLVABLE_HELPERS") + assert len(messages) == 1, messages + msg = messages[0] + # Names the key... + assert "_RESOLVABLE_HELPERS[('tan/commands/selftest_cmd.py', '_selftest_helper')]" in msg, msg + # ...and BOTH counts: the declared expectation and the actual finding. + assert "declares sites=1" in msg, msg + assert "found 2" in msg, msg + assert "tan/commands/selftest_cmd.py:10" in msg and "tan/commands/selftest_cmd.py:20" in msg, msg + + # The _ACKNOWLEDGED_CEILINGS half -- same function, same synthetic + # mismatch, different bucket label -- doubly unproven beforehand since + # that table carries zero real entries to ever drive this path. + ceiling_messages = _check_site_counts(declared, seen_mismatched, "_ACKNOWLEDGED_CEILINGS") + assert len(ceiling_messages) == 1, ceiling_messages + assert ceiling_messages[0].startswith("_ACKNOWLEDGED_CEILINGS[("), ceiling_messages[0] + assert "declares sites=1" in ceiling_messages[0] and "found 2" in ceiling_messages[0], ceiling_messages[0] + + # And the negative: a `seen` count that matches `declared` produces no + # message at all -- proving this is sensitive to the mismatch, not + # unconditionally red. + assert _check_site_counts(declared, {key: [10]}, "_RESOLVABLE_HELPERS") == [] + + # And the mirrored direction: a declared key with NOTHING seen at all + # (the "a declared site disappeared" branch) is exactly as loud. + vanished = _check_site_counts(declared, {}, "_RESOLVABLE_HELPERS") + assert len(vanished) == 1, vanished + assert "declares sites=1" in vanished[0] and "found 0" in vanished[0], vanished[0] + + +def test_prefix_template_scan_finds_a_fresh_synthetic_site(tmp_path: pathlib.Path): + """A second self-test, at the AST-mechanics level rather than the + registry-diff level: [`_prefix_templates`] and [`_literal_codes_in_file`] + are exercised here against SYNTHETIC source containing codes neither has + ever seen. Proves the SCANNER notices a new site, not just that the diff + logic notices a missing registration. + + Written under pytest's own `tmp_path`, NOT under `TAN` (tan-cli#224 + review): `_literal_codes_in_file`/`_prefix_templates` take an arbitrary + path (`_rel` falls back to the path unchanged outside `tan/`, see its own + docstring), so nothing here needs to live inside the real package -- and a + scratch file that DID would be one interrupted run away from surviving + into `python/tan/` itself (a stray file in the very tree every other test + in this file globs with `TAN.rglob("*.py")`), the production-tree escape + this fix closes. + """ + synthetic = tmp_path / "selftest_scratch.py" + synthetic.write_text( + "from __future__ import annotations\n" + "\n" + 'SELFTEST_CONST = "selftest.const-code"\n' + "\n" + "\n" + "def emit_one(reg):\n" + ' return Issue(SELFTEST_CONST, "error", "x")\n' + "\n" + "\n" + "def emit_two(code):\n" + ' return Issue(f"selftestfamily.{code}", "error", "x")\n' + "\n" + "\n" + "def emit_three():\n" + ' return Check("n", "fail", "d", code="selftest.kwarg-code")\n', + encoding="utf-8", + ) + literal, unresolved = _literal_codes_in_file(synthetic) + assert literal == {"selftest.const-code", "selftest.kwarg-code"}, literal + assert unresolved == [], unresolved + + templates = _prefix_templates(synthetic) + assert templates == [(11, "selftestfamily.", "code", "prefix", "emit_two")], templates diff --git a/python/tests/parity/conftest.py b/python/tests/parity/conftest.py new file mode 100644 index 00000000..cc040e24 --- /dev/null +++ b/python/tests/parity/conftest.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Shared fixtures for every test module under ``tests/parity/``. + +Home of the one check that used to live inside ``test_oracle_parity.py`` +alone, opted into by a handful of cases there and inherited by nobody else: +proof that whatever ``target/{release,debug}/tan`` :func:`oracle.rust_binary` +resolved is actually the pinned oracle this whole suite is measured against, +not a stale profile silently standing in for it. + +``test_flash_oracle_parity.py``, ``test_image_size_oracle.py``, +``test_clean_parity.py`` and ``test_run_oracle_parity.py`` each bind +``RUST = rust_binary()`` at import time and gate their live cases on +``missing_for_live`` (an ABSENCE check only) with no version assertion of +their own. Under a live run (``TAN_PARITY_LIVE=1``) against a resolved-but- +wrong binary, every one of those files' failures reads as a wall of PORT +bugs, with nothing naming the oracle itself as the actual problem -- measured: +``TAN_PARITY_LIVE=1 TAN_RUST_BINARY=`` over just +``test_image_size_oracle.py`` + ``test_clean_parity.py`` produced "108 +failed, 8 passed, 9 skipped, 2 xfailed", eight of those passes measured +against the wrong oracle entirely. + +Fixing this per-file (each module opting into its own copy of the check, the +way ``test_oracle_parity.py``'s old ``pinned_oracle`` did) would need to land +in four places every time the pin changes. A session-scoped, autouse fixture +here is inherited by every module in this directory for free, including +``test_oracle_parity.py`` itself, which no longer defines its own copy. +""" +import subprocess + +import pytest + +from .oracle import PINNED_ORACLE_VERSION, rust_binary + +@pytest.fixture(scope="session", autouse=True) +def pinned_oracle() -> None: + """FAIL the session -- never skip -- when a resolved oracle binary is + present but does not report :data:`oracle.PINNED_ORACLE_VERSION`. + + Absence stays a skip: that is what each file's own ``missing_for_live``/ + ``_ORACLE_REQUIRED`` gate already decides, and this fixture defers to it + entirely by doing nothing when ``rust_binary()`` returns ``None``. Only WRONGNESS + fails here -- a resolved binary that answers the wrong ``--version`` -- + because a quiet skip in that case would hide exactly the gap this + harness exists to surface (``missing_for_live``'s own docstring makes the + identical argument for the absence case; tan-cli#272 is where that rule + was first written down, and it applies just as hard to wrongness as to + absence). + + Session-scoped so this runs the check ONCE per test process rather than + once per test case; the resolution cannot change mid-session, so + re-checking it per-test bought nothing but 17 extra ``--version`` + subprocesses a run. + + ``rust_binary()`` is called HERE, in the fixture body, and deliberately + NOT at this conftest's import. It can raise -- a ``TAN_RUST_BINARY`` that + is set but missing, or the mtime TIE between target/{release,debug} that + it now refuses to break silently -- and a raise at conftest import is an + ``ImportError while loading conftest``, which aborts the WHOLE pytest + session rather than this directory. Measured: with a bogus + ``TAN_RUST_BINARY``, ``pytest tests/parity tests/core`` collected zero + tests and exited rc=4, so the repo's own ``python -m pytest tests -q`` + gate would have reported nothing at all. Resolving in the fixture keeps + the blast radius on ``tests/parity``, which is what it is about. + """ + rust = rust_binary() + if rust is None: + return + proc = subprocess.run([rust, "--version"], capture_output=True, text=True, encoding="utf-8") + assert proc.returncode == 0, f"{rust} is not a working tan binary" + stdout = proc.stdout.strip() + assert stdout == PINNED_ORACLE_VERSION, ( + f"resolved oracle {rust!r} reports {stdout!r}, not the pinned " + f"{PINNED_ORACLE_VERSION!r} this whole parity suite is measured " + "against. oracle.rust_binary() picks the MOST RECENTLY BUILT of " + "target/{release,debug}/tan, so a resolved-but-wrong binary means " + "either an inverted or TIED mtime between the two profiles (a tie " + "raises inside rust_binary() itself -- see that function) or an " + "explicit TAN_RUST_BINARY naming the wrong one. Rebuild or remove " + "the stale profile, or set TAN_RUST_BINARY= explicitly." + ) diff --git a/python/tests/parity/oracle.py b/python/tests/parity/oracle.py index 4f066bd4..cf6bf78b 100644 --- a/python/tests/parity/oracle.py +++ b/python/tests/parity/oracle.py @@ -22,9 +22,10 @@ ``VERSION`` Exit code plus the *shape* of the version line. The literals differ BY - DESIGN and permanently: the port declares 0.5.0-dev, the checked-out Rust - declares 0.4.1-dev (``python/tan/version.py`` records why the port must not - reuse the shipped number). What the extension actually contracts on is the + DESIGN and permanently: the port declares ``0.5.0-rc3`` + (``python/tan/version.py``, which records why the port must not reuse the + shipped number), the checked-out Rust declares ``0.4.1`` + (``Cargo.toml``). What the extension actually contracts on is the regex ``/^tan \\d+\\.\\d+\\.\\d+/`` (alp-sdk-vscode/src/alpCli/service.ts:107-121), so that is what is compared -- and a side that fails the regex outright is reported even when both @@ -36,7 +37,8 @@ compare that would enforce it. Prefix-anchoring here silently accepted ``"tan 0.5.0-dev\\nLEAKED EXTRA STDOUT LINE"`` and ``"tan 9.9.9 THIS IS NOT TAN AT ALL"`` as parity -- on the one case that - actually runs today. Rust prints exactly ``tan 0.4.1-dev``. + actually runs today. Rust prints exactly ``tan 0.4.1`` (see + :data:`PINNED_ORACLE_VERSION`, the one place that spelling is owned). ``PLAN`` Exit code, the envelope shell, and -- inside ``data`` -- a NARROWED view of @@ -78,6 +80,7 @@ import json import os import re +import shutil import subprocess import sys from dataclasses import dataclass @@ -202,16 +205,56 @@ class ParityResult: diffs: list[str] +#: The exact ``--version`` line the whole parity suite is measured against. +#: Not ``0.4.1-dev``: that spelling is this repo's OLDER PROSE for the RUST +#: oracle, from before ``Cargo.toml`` settled on ``version = "0.4.1"``, which +#: is why the binary prints no suffix today. It has never been the PORT's +#: string -- ``python/tan/version.py`` declares ``TAN_VERSION = "0.5.0-rc3"``. +#: The checked-out Rust binary's actual stdout, byte for byte, is +#: ``tan 0.4.1`` with no suffix (verified directly: ``tan --version | cat -A`` +#: -> ``tan 0.4.1$``). Shared here, not owned by any one test module, because +#: ``conftest.py``'s session-scoped ``pinned_oracle`` fixture checks against +#: it for every file under ``tests/parity/``, not just the module that used to +#: define it alone. +PINNED_ORACLE_VERSION = "tan 0.4.1" + + def rust_binary() -> str | None: - """``TAN_RUST_BINARY`` if set, else a build in the usual places. Returns - ``None`` only when NOBODY named an oracle and none was built, so the caller - can skip rather than invent a comparison. + """``TAN_RUST_BINARY`` if set, else the MOST RECENTLY BUILT of + ``target/{release,debug}/tan``. Returns ``None`` only when NOBODY named an + oracle and none was built, so the caller can skip rather than invent a + comparison. A set-but-missing ``TAN_RUST_BINARY`` RAISES instead. It must not fall back to some other binary -- an operator who named one should not silently get a different one -- but it must not skip either: a typo'd path in CI would then produce an all-skip, all-green run that certifies nothing, which is the exact failure mode this harness exists to prevent. + + When both profiles exist, the choice is by ``st_mtime``, not a fixed + release-over-debug preference (what this function used to do, + unconditionally). That preference used to silently pick a STALE binary: a + ``target/release/tan`` left over from a much earlier build (measured -- + ``tan 0.1.1``, weeks old) sitting next to a freshly rebuilt + ``target/debug/tan`` (``tan 0.4.1``) always won, because ``release`` was + tried first regardless of either file's age, and every unpinned caller of + this function silently measured against the wrong oracle with no signal + that anything was off. mtime is what an ordinary edit-build-test loop + actually implies: whichever profile was rebuilt LAST is the one a + developer (or CI job that just ran ``cargo build``) intends to test + against right now. A caller that legitimately wants one profile over the + other regardless of freshness should set ``TAN_RUST_BINARY`` explicitly + rather than rely on this default. + + A TIE (identical ``st_mtime`` on both profiles) is refused outright rather + than resolved silently -- silently is what this function used to do, and + it always broke to ``target/release``, which is exactly the stale-pick bug + the mtime rule above replaced. A CI cache restore, ``cp -p``, rsync, or an + artifact download can equalise (or invert) mtimes on ``target/`` with no + rebuild involved, so a tie is a real, reachable way to reinstate that bug + for every caller that does not separately assert the resolved version (see + ``conftest.py``'s ``pinned_oracle``, which is that safety net -- this raise + is the thing it nets). """ override = os.environ.get("TAN_RUST_BINARY") if override: @@ -222,11 +265,25 @@ def rust_binary() -> str | None: "Fix the path, or unset it to fall back to target/{release,debug}." ) return override - for profile in ("release", "debug"): - candidate = REPO_ROOT / "target" / profile / f"tan{_EXE}" - if candidate.exists(): - return str(candidate) - return None + candidates = [ + REPO_ROOT / "target" / profile / f"tan{_EXE}" for profile in ("release", "debug") + ] + existing = [c for c in candidates if c.exists()] + if not existing: + return None + newest_mtime = max(c.stat().st_mtime for c in existing) + tied = [c for c in existing if c.stat().st_mtime == newest_mtime] + if len(tied) > 1: + raise RuntimeError( + "target/release/tan and target/debug/tan report the IDENTICAL " + f"mtime ({newest_mtime!r}): {', '.join(str(c) for c in tied)}. " + "Refusing to pick one silently -- a tie used to break to " + "target/release unconditionally, which is the exact stale-binary " + "bug the mtime rule this function now uses was written to " + "replace. Rebuild one of the two profiles to break the tie, or " + "set TAN_RUST_BINARY= to the one you mean." + ) + return str(tied[0]) def missing_for_live(rust: str | None) -> bool: @@ -243,6 +300,86 @@ def missing_for_live(rust: str | None) -> bool: return oracle_fixtures.LIVE and rust is None +def empty_tool_inventory(scratch: Path) -> str: + """A ``PATH`` value that resolves NOTHING except ``which`` itself -- the + general form of ``test_flash_oracle_parity.py``'s ``_pin_tool_inventory`` + for a case whose frozen fixture answer is "no such tool anywhere on PATH", + not "found this specific stand-in". Any ``shutil.which``/ + ``doctor_cmd.on_path`` probe run against this directory reports every + PROBED name absent -- ``west`` for ``test_west_forward_matches_rust`` + today, ``git``/``cmake``/``ninja``/``python3``/``xz``/``wget`` for + ``support-bundle.hostPrerequisites``, and any other which()-gated case's + absent-tool branch tomorrow -- matching whatever tool inventory a + fixture's capture host happened to lack (tan-cli#313, tan-cli#324), + without inventing a second bespoke per-file helper for each one that + comes up. + + Seeds the directory with exactly one file: a symlink to the REAL + ``which`` this host resolves on its own PATH, POSIX only (a no-op on + Windows, whose probe -- ``crate::util::windows_path_lookup`` -- walks + ``%PATH%`` by hand and never spawns an external ``which`` at all). A + directory that is genuinely, literally empty is NOT a clean "nothing on + PATH" answer on POSIX: the oracle's own probe + (``crates/tan-cli/src/util.rs:35-50``, ``command_on_path``) resolves a + tool by SPAWNING ``which `` as a subprocess, and that spawn itself + has to find ``which`` via this SAME (oracle-controlled) PATH. An empty + directory can't resolve ``which`` either, so the probe fails before it + ever answers the question asked -- measured directly: a PATH holding + every one of ``support-bundle``'s six required tools but NOT ``which`` + still reports all six missing, and copying a working ``which`` in + (touching nothing else) makes that same warning vanish entirely. A + literally-empty directory's "everything missing" answer was an artefact + of the unresolvable ``which`` spawn, not a real absence measurement -- + see ``_DEFERRED_VERBS``'s own comment for what the pinned + ``support-bundle`` answer means now that this seeds a working ``which``: + a GENUINE probe that ran and found nothing, not a degenerate one that + could not run at all. + + REPLACES ``PATH`` outright rather than prepending, for the identical + reason ``_pin_tool_inventory`` does: prepending would still let a REAL + tool further down the replay host's own PATH be found, which is exactly + the host-dependence this exists to remove. + + Hand the result to :func:`compare`'s ``python_env_overrides`` -- e.g. + ``{"PATH": empty_tool_inventory(tmp_path)}`` -- for a FROZEN-REPLAY + caller, and never fabricate the equivalent for the rust side THERE: the + frozen answer is a recorded fixture, and pinning only the python side's + PATH is exactly right in that mode (see that parameter's own docstring + for why forwarding the same pin to the rust side under + ``TAN_PARITY_LIVE=1`` is not a safe substitute -- the whole tan-cli#313/ + #324 bug was one side pinned and the other left to whatever happened to + be installed). That ban is scoped to ``compare()``'s frozen-replay path -- + it does not reach a case that spawns BOTH binaries live on every run + instead (e.g. ``test_deferred_verb_is_a_known_divergence_from_the_ + oracle``): there, applying this SAME value to both sides' subprocess + environments is sound, because it is one identical override applied + twice, symmetrically, not a pin smuggled onto one side only. + """ + stub_dir = scratch / "empty-path" + stub_dir.mkdir(exist_ok=True) + if sys.platform != "win32": + # The seed is NOT optional. A literally-empty PATH makes the oracle's + # POSIX probe unable to resolve `which` ITSELF, so its tool report goes + # degenerate: it names every tool missing because it could not look, + # not because they are absent. A caller pinning that answer measures a + # broken probe. Refuse rather than silently return to it (tan-cli#313, + # tan-cli#324 -- the same silent-degradation class both closed). + real_which = shutil.which("which") + if real_which is None: + raise RuntimeError( + "empty_tool_inventory() cannot seed `which` into the stub PATH: " + "shutil.which('which') returned None on this POSIX host. Without " + "it the oracle's tool probe cannot run at all and reports every " + "tool missing for the wrong reason -- refusing rather than " + "pinning that artefact." + ) + link = stub_dir / "which" + if not link.exists(): + os.symlink(real_which, link) + assert link.exists(), f"failed to seed `which` into {stub_dir}" + return str(stub_dir) + + def python_command() -> list[str]: """The port under test. Defaults to the source tree so the harness runs without a packaging step; ``TAN_PYTHON_BINARY`` points it at the PyInstaller @@ -270,7 +407,22 @@ def _env(home: Path) -> dict[str, str]: } -def _run(command: list[str], argv: list[str], cwd: Path, home: Path): +def _run( + command: list[str], + argv: list[str], + cwd: Path, + home: Path, + *, + env_overrides: dict[str, str] | None = None, +): + """``env_overrides`` layers on top of :func:`_env`'s shared environment -- + e.g. pinning ``PATH`` so a tool-presence probe INSIDE the spawned process + (``shutil.which``/``doctor_cmd.on_path``) cannot pick up whatever happens + to be installed on whichever host is replaying this suite (tan-cli#313). + Empty by default, so every existing caller is unaffected.""" + env = _env(home) + if env_overrides: + env = {**env, **env_overrides} proc = subprocess.run( [*command, *argv], capture_output=True, @@ -281,7 +433,7 @@ def _run(command: list[str], argv: list[str], cwd: Path, home: Path): encoding="utf-8", errors="replace", cwd=cwd, - env=_env(home), + env=env, ) try: payload = json.loads(proc.stdout) @@ -355,6 +507,7 @@ def compare( home: Path | None = None, python: list[str] | None = None, extra_scrub_roots: tuple[Path | str, ...] = (), + python_env_overrides: dict[str, str] | None = None, ) -> ParityResult: """Diff the two binaries on ``argv``, scoped to ``surface``. @@ -374,12 +527,57 @@ def compare( cover -- e.g. a checked-in fixture file (a ``--plan-from`` plan) that itself embeds the path of whatever alp-sdk checkout it was captured against. Empty by default, so every existing caller is unaffected. + + ``python_env_overrides`` (tan-cli#313) layers extra environment onto the + PYTHON side's subprocess ONLY, never the rust side's -- and is REFUSED + outright whenever ``oracle_fixtures.LIVE`` is set, rather than silently + applied to one side only. In frozen replay (the default, no + ``TAN_PARITY_LIVE``) the rust side never spawns anything at all, so + pinning only the python side is exactly right: the frozen fixture IS the + rust answer, already captured against a specific tool inventory, and the + python side's live PATH probe needs pinning to match it, or the + comparison depends on what happens to be installed on whoever runs the + suite. See ``test_flash_oracle_parity.py``'s ``_pin_tool_inventory``. + + Under ``TAN_PARITY_LIVE=1`` both sides DO spawn, and ``_env``'s own + invariant applies in full ("one environment, shared by both sides -- + the whole point is that the only difference between the two runs is the + implementation"): applying the pin to python alone would silently break + that invariant for exactly the pinned cases, and forwarding it to + ``rust_run`` too is NOT a safe substitute -- measured against the real + oracle (``target/debug/tan``), POSIX ``command_on_path`` + (``crates/tan-cli/src/util.rs:45-50``) resolves a tool by SPAWNING + ``which`` as its own subprocess, which itself has to be found via the + (now-pinned, ``which``-free) ``PATH`` -- so a PATH replaced with only a + ``dd`` stand-in makes ``which`` itself unresolvable, every rust-side + probe report "not found" including ``dd``, and the SAME pin that makes + python answer "dd" makes rust answer "bmaptool" -- the opposite tool, on + the identical override. So under ``TAN_PARITY_LIVE=1`` this parameter + raises rather than comparing two binaries under two different (or two + subtly-broken-in-opposite-directions) environments; the caller should + drop the pin for a live run (both binaries then share this host's REAL + tool inventory, which is the whole point of a live re-validation) and + keep it only for frozen replay. ``None`` by default, so every existing + caller is unaffected. """ home = home or cwd roots = (cwd, home, *extra_scrub_roots) + if oracle_fixtures.LIVE and python_env_overrides: + raise RuntimeError( + "compare() got python_env_overrides under TAN_PARITY_LIVE=1: both " + "binaries spawn for real in this mode, and pinning only the " + "python side's PATH breaks _env's 'one environment, shared by " + "both sides' invariant -- see this parameter's own docstring for " + "why forwarding the same pin to the rust side is not a safe fix " + "either (tan-cli#313). Drop python_env_overrides for a live run, " + "or drop TAN_PARITY_LIVE to replay the frozen fixture with it." + ) + r_code, r_out = rust_run(argv, cwd, home, scrub_roots=roots) - p_code, p_out = _run(python or python_command(), argv, cwd, home) + p_code, p_out = _run( + python or python_command(), argv, cwd, home, env_overrides=python_env_overrides + ) p_out = oracle_fixtures.scrub(p_out, *roots) # Scoped to PATH_KEYS, on BOTH sides, before the diff -- see that # constant's own docstring. A no-op whenever the two sides already agree diff --git a/python/tests/parity/oracle_fixtures/PROVENANCE.txt b/python/tests/parity/oracle_fixtures/PROVENANCE.txt index 6a241550..41133008 100644 --- a/python/tests/parity/oracle_fixtures/PROVENANCE.txt +++ b/python/tests/parity/oracle_fixtures/PROVENANCE.txt @@ -162,3 +162,44 @@ returns nothing, `tests/gates/test_no_leaked_host_paths.py` passes with every file in this directory (and this file's own new `test_oracle_fixtures.py`) tracked, and the other four fixture files in this directory are byte- identical to before this pass (`git diff --stat` on each is empty). + +-------------------------------------------------------------------------- + +THE CAPTURE HOST'S TOOL INVENTORY IS PART OF THE FROZEN ANSWER +(tan-cli#313, tan-cli#324) + +Eight cases branch on a `which()` probe, so their frozen answers encode which +tools the CAPTURE host had -- not just what the oracle computes. The capture +host had: + + west ABSENT -- the three west_forward cases froze the + "west not found on PATH" launch error + dd PRESENT -- the yocto_wic cases froze "would run dd if=..." + bmaptool ABSENT -- else those cases would have frozen a bmaptool argv + +Replay pins the PYTHON side's PATH to match that inventory, so the comparison +does not silently measure the replay host instead of the port. The pins: + + test_flash_oracle_parity.py `_TOOL_PROBE_PINNED_CASES` (5 cases) -> + `_pin_tool_inventory`, a scratch PATH holding a stand-in `dd` only + test_oracle_parity.py `test_west_forward_matches_rust[migrate|lock|quality]` + (3 cases) -> `oracle.empty_tool_inventory`, a scratch PATH holding nothing + +CONSEQUENCE FOR RE-CAPTURE, and the reason this section exists: the recipe at +the top of this file NO LONGER WORKS AS WRITTEN for these eight. Under +TAN_PARITY_LIVE=1 both sides genuinely spawn, so `compare()` REFUSES a +`python_env_overrides` rather than compare one pinned side against one +unpinned side -- deliberately, since that would be a fresh divergence rather +than a capture. Measured: + + TAN_PARITY_LIVE=1 python -m pytest \ + "tests/parity/test_oracle_parity.py::test_west_forward_matches_rust" + -> 3 failed, RuntimeError: compare() got python_env_overrides under + TAN_PARITY_LIVE=1 + +To re-capture any of the eight, run the capture on a host whose tool +inventory MATCHES the table above (no `west`, no `bmaptool`, a real `dd`) and +drop the pin for that run. Capturing on a host with `west` installed and the +pin dropped would freeze a different answer entirely -- one that then only +replays on hosts that also have `west`, reintroducing exactly the bug +tan-cli#324 closed. diff --git a/python/tests/parity/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..59cf90f0 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, @@ -63,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 @@ -205,11 +207,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 +240,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) @@ -597,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 @@ -623,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')", ],