From 74823a7708d095288ecb14ff4e5e0326b942eb64 Mon Sep 17 00:00:00 2001 From: Caner Alp Date: Sun, 16 Aug 2026 17:36:19 +0000 Subject: [PATCH 1/6] fix(envelope): reach text mode with the discovery-divergent seam, and give run --confirm Fixes #799: size/image/clean/run/validate each constructed their Envelope only inside `if json_mode:` and rendered text from a local issues/text list built strictly before that -- so the sdk.discovery-divergent warning Envelope.__init__ appends at its one shared seam reached --format json and stayed silent on the default text channel. pinmux_cmd.py was already correct (builds the envelope once, unconditionally, renders text from envelope.issues); this applies the same pattern to the other five, diffing each command's pre-envelope issues against envelope.issues so only what the seam actually added is rendered. Fixes #809: `run` had no way to arm the flash confirm gate, so a hardware target (e.g. E1M-AEN801 Flow D) always landed every slice as `planned` and exited 1 with flash.nothing-flashed -- whose own remedy names --confirm, a flag `run` rejected. Adds --confirm to `run` and threads it into flash_cmd._run as confirm_flag, and updates the README quickstart, walkthrough step, and Common-commands table to show and explain the gate. The gate itself stays gate-by-default, unchanged. --- README.md | 16 +++-- python/changelog.d/799.fixed.md | 14 ++++ python/changelog.d/809.fixed.md | 12 ++++ python/tan/commands/clean_cmd.py | 29 +++++--- python/tan/commands/image_cmd.py | 30 +++++--- python/tan/commands/run_cmd.py | 42 ++++++++++- python/tan/commands/size_cmd.py | 31 +++++--- python/tan/commands/validate_cmd.py | 72 +++++++++++-------- .../commands/test_sdk_discovery_ladders.py | 71 ++++++++++++++++++ python/tests/gates/MODULE_SIZE_BUDGET_LOG.md | 4 ++ 10 files changed, 258 insertions(+), 63 deletions(-) create mode 100644 python/changelog.d/799.fixed.md create mode 100644 python/changelog.d/809.fixed.md diff --git a/README.md b/README.md index 0deac1ef..70a5e690 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ cd my-app tan validate tan build tan size -tan run --flash +tan run --flash --confirm ``` What those commands do: @@ -190,7 +190,15 @@ What those commands do: 4. `validate` checks `board.yaml` and related metadata. 5. `build` plans, materialises, and builds every core slice. 6. `size` reports firmware use against the SoM memory budget. -7. `run --flash` builds and then runs or programs the selected target. +7. `run --flash` builds and then runs or programs the selected target. On a + hardware target (a native_sim/host target always just runs), `--flash` + alone only *previews* the write: every slice comes back `planned`, nothing + reaches the device, and the run exits non-zero naming the remedy. Add + `--confirm` (as above) to actually arm the write, or set + `ALP_FLASH_FORCE=1` in the environment, or `flash_args.confirm: true` in + the manifest -- the same three-way gate `tan flash --confirm` already has. + This is deliberate, not a bug: a fresh checkout must not silently + reprogram an attached module. Run `tan doctor` if setup or toolchain discovery fails; its `zephyrSdk` check names the exact `west sdk install` command above too, so it stays @@ -224,8 +232,8 @@ move. | Create a project | `tan init --name my-app` | | Check a project | `tan validate` | | Build firmware | `tan build` | -| Build and run or flash | `tan run --flash` | -| Flash an existing build | `tan flash` | +| Build and run or flash | `tan run --flash --confirm` (`--confirm` arms the write on a hardware target; see the quickstart) | +| Flash an existing build | `tan flash --confirm` | | Inspect firmware size | `tan size` | | Create an image | `tan image` | | Remove build output | `tan clean` | diff --git a/python/changelog.d/799.fixed.md b/python/changelog.d/799.fixed.md new file mode 100644 index 00000000..5efb59ea --- /dev/null +++ b/python/changelog.d/799.fixed.md @@ -0,0 +1,14 @@ +- **`tan size`/`image`/`clean`/`run`/`validate` now print the `sdk.discovery- + divergent` two-checkout warning on the default text channel, not only under + `--format json`.** `Envelope.__init__` appends that warning at the one seam + every command's envelope passes through (`_with_sdk_divergence`), but each of + these five built the `Envelope` only inside `if json_mode:`, after their + text-mode lines had already been assembled from the pre-envelope `issues` + list — so a workspace resolving two different alp-sdk checkouts got the + collision warning from `tan build`/`tan pinmux`/`tan doctor` and silence from + the other five, the exact channel a human reads. `pinmux_cmd.py` was already + correct (builds the envelope once, unconditionally, and renders text from + `envelope.issues`); this applies the same fix to the other five, diffing + each command's own pre-envelope issue list against `envelope.issues` so only + what the seam actually added is rendered, never a duplicate of a warning the + command already prints by hand. diff --git a/python/changelog.d/809.fixed.md b/python/changelog.d/809.fixed.md new file mode 100644 index 00000000..553a8960 --- /dev/null +++ b/python/changelog.d/809.fixed.md @@ -0,0 +1,12 @@ +- **`tan run --flash` can now arm the flash confirm gate directly, with a new + `--confirm` option threaded into the flash engine as `confirm_flag`.** + Without it, a hardware target (an E1M-AEN801 Flow D target among them) always + came back with every slice `planned`, wrote nothing, and exited 1 with + `flash.nothing-flashed` — whose own remedy names `--confirm` on the command + line, a flag `tan run` rejected outright. The gate itself stays gate-by- + default on purpose (a fresh checkout must not silently reprogram an attached + module); this only gives `run` the same explicit opt-in `tan flash --confirm` + already had, so the remedy the tool prints is one `run` itself can act on. + The README quickstart, walkthrough step, and Common-commands table now show + and explain `--confirm` instead of ending on a command that exits non-zero on + the flagship target. diff --git a/python/tan/commands/clean_cmd.py b/python/tan/commands/clean_cmd.py index 62215f05..fe2380de 100644 --- a/python/tan/commands/clean_cmd.py +++ b/python/tan/commands/clean_cmd.py @@ -1091,20 +1091,29 @@ def clean( text=["clean: internal failure"], ) + # Built ONCE, for both formats: `Envelope.__init__` appends the tan-cli#407 + # `sdk.discovery-divergent` warning at the shared seam (`_with_sdk_ + # divergence`), and `outcome.text` was assembled strictly before any + # `Envelope` existed -- so a seam-appended issue reached `--format json` + # and was silent on the default text channel (tan-cli#799). Diffed + # against `outcome.issues` (by value: `Issue` is a frozen dataclass) so + # only what the seam ADDED is rendered. + envelope = Envelope( + "clean", + outcome.project, + outcome.data, + outcome.issues, + outcome.exit_code, + sdk=outcome.sdk, + ) if json_mode: - emit( - Envelope( - "clean", - outcome.project, - outcome.data, - outcome.issues, - outcome.exit_code, - sdk=outcome.sdk, - ) - ) + emit(envelope) else: + seam_extra = [issue for issue in envelope.issues if issue not in outcome.issues] # stdout is the envelope channel and carries nothing else, in either # mode; stderr carries no contract of its own. + for issue in seam_extra: + print(f"{issue.severity}: {issue.message}", file=sys.stderr) for line in outcome.text: print(line, file=sys.stderr) raise typer.Exit(int(outcome.exit_code)) diff --git a/python/tan/commands/image_cmd.py b/python/tan/commands/image_cmd.py index b41cb1b6..a13fe46d 100644 --- a/python/tan/commands/image_cmd.py +++ b/python/tan/commands/image_cmd.py @@ -674,19 +674,29 @@ def image( disclosure.sdk, ) + # Built ONCE, for both formats: `Envelope.__init__` appends the tan-cli#407 + # `sdk.discovery-divergent` warning at the shared seam (`_with_sdk_ + # divergence`), and `outcome.text` was assembled strictly before any + # `Envelope` existed -- so a seam-appended issue reached `--format json` + # and was silent on the default text channel (tan-cli#799). Diffed + # against `outcome.issues` (by value: `Issue` is a frozen dataclass) so + # only what the seam ADDED is rendered, never a duplicate of a warning + # `outcome.text` already carries via `_sdk_warning_lines` above. + envelope = Envelope( + "image", + outcome.project, + outcome.data, + outcome.issues, + outcome.exit_code, + sdk=outcome.sdk, + ) if json_mode: - emit( - Envelope( - "image", - outcome.project, - outcome.data, - outcome.issues, - outcome.exit_code, - sdk=outcome.sdk, - ) - ) + emit(envelope) else: + seam_extra = [issue for issue in envelope.issues if issue not in outcome.issues] stream = typer.get_text_stream("stderr") + for issue in seam_extra: + stream.write(f"{issue.severity}: {issue.message}\n") for line in outcome.text: stream.write(f"{line}\n") raise typer.Exit(int(outcome.exit_code)) diff --git a/python/tan/commands/run_cmd.py b/python/tan/commands/run_cmd.py index 1a14c8ed..bd0f7f15 100644 --- a/python/tan/commands/run_cmd.py +++ b/python/tan/commands/run_cmd.py @@ -254,6 +254,12 @@ def _run( flash: bool, core: str | None, json_mode: bool, + # Defaulted, not required: several existing unit tests call `_run` + # directly with the pre-tan-cli#809 kwarg set, exercising paths this flag + # never reaches (`BUILD_ONLY`/`MANIFEST_STALE`/`EXECUTE_NATIVE`) or + # stubbing `flash_cmd._run` themselves. `False` preserves the exact + # pre-#809 behaviour for every one of them. + confirm: bool = False, ) -> tuple[ExitCode, dict[str, Any] | None, list[Issue], list[str]]: """Everything between the resolved paths and the envelope. Returns `(exit_code, data, issues, text_lines)`.""" @@ -317,6 +323,13 @@ def _run( skip_missing_tools=False, capture=json_mode, cwd=build_root, + # tan-cli#809: `run` had no way to arm the flash confirm gate, so a + # hardware target (e.g. E1M-AEN801's Flow D) always landed every + # slice as `planned` and exited 1 with `flash.nothing-flashed` -- + # whose own remedy names `--confirm`, a flag `run` rejected. The gate + # itself stays deliberate (see flash_plan.py); this only gives `run` + # the same opt-in `tan flash --confirm` already has. + confirm_flag=confirm, ) return flash_exit, flash_data, flash_issues, flash_text @@ -337,6 +350,15 @@ def run( help="With --flash, flash only the slice with this core_id (forwarded " "verbatim to the native flash path's --core).", ), + confirm: bool = typer.Option( + False, + "--confirm", + help="With --flash, arm the confirm gate and actually write the device. " + "Without it (and without ALP_FLASH_FORCE=1 or flash_args.confirm: true " + "in the manifest) a hardware target is only previewed -- every slice " + "comes back `planned`, nothing reaches the device, and the run exits " + "non-zero (tan-cli#809). Ignored without --flash.", + ), project: str = typer.Option( None, "--project", metavar="PATH", help="Project root (defaults to '.')." ), @@ -418,6 +440,7 @@ def run( board_yaml=board_yaml, flash=flash, core=core, + confirm=confirm, json_mode=json_mode, ) except Exception as err: # noqa: BLE001 -- see build_cmd.build's identical guard @@ -452,8 +475,25 @@ def run( f"{issue.severity}: {issue.message}" for issue in issues[:warning_count] ] + text_lines + # Built ONCE, for both formats: `Envelope.__init__` appends the + # tan-cli#407 `sdk.discovery-divergent` warning at the shared seam + # (`_with_sdk_divergence`), beyond the pin/foreign pair this function + # already prepends by hand above (tan-cli#464). Text mode used to render + # `text_lines`, built before any `Envelope` existed, so a seam-appended + # divergence issue reached `--format json` and was silent on the default + # channel (tan-cli#799) -- diffed against the pre-envelope `issues` list + # (by value: `Issue` is a frozen dataclass) so only what the seam ADDED + # is rendered, never a duplicate of the pin/foreign pair already in + # `text_lines`. + envelope = Envelope("run", project_obj, data, issues, exit_code, sdk=sdk) + seam_extra = [issue for issue in envelope.issues if issue not in issues] + if seam_extra: + text_lines = [ + f"{issue.severity}: {issue.message}" for issue in seam_extra + ] + text_lines + if json_mode: - emit(Envelope("run", project_obj, data, issues, exit_code, sdk=sdk)) + emit(envelope) else: for line in text_lines: print(line, file=sys.stderr) diff --git a/python/tan/commands/size_cmd.py b/python/tan/commands/size_cmd.py index 7bf58664..187a889f 100644 --- a/python/tan/commands/size_cmd.py +++ b/python/tan/commands/size_cmd.py @@ -802,21 +802,32 @@ def size( disclosure.sdk, ) + # Built ONCE, for both formats: `Envelope.__init__` appends the tan-cli#407 + # `sdk.discovery-divergent` warning at the shared seam (`_with_sdk_ + # divergence`), and `outcome.text` was assembled strictly before any + # `Envelope` existed -- so a seam-appended issue reached `--format json` + # and was silent on the default text channel (tan-cli#799). Diffed + # against `outcome.issues` (by value: `Issue` is a frozen dataclass) so + # only what the seam ADDED is rendered, never a duplicate of a warning + # `outcome.text` already carries (the pair `_run`/`_error_outcome` render + # via `_sdk_warning_lines` above). + envelope = Envelope( + "size", + outcome.project, + outcome.data, + outcome.issues, + outcome.exit_code, + sdk=outcome.sdk, + ) if json_mode: - emit( - Envelope( - "size", - outcome.project, - outcome.data, - outcome.issues, - outcome.exit_code, - sdk=outcome.sdk, - ) - ) + emit(envelope) else: + seam_extra = [issue for issue in envelope.issues if issue not in outcome.issues] # stdout is the envelope channel and carries nothing else, in either # mode; stderr carries no contract of its own. stream = typer.get_text_stream("stderr") + for issue in seam_extra: + stream.write(f"{issue.severity}: {issue.message}\n") for line in outcome.text: stream.write(f"{line}\n") raise typer.Exit(int(outcome.exit_code)) diff --git a/python/tan/commands/validate_cmd.py b/python/tan/commands/validate_cmd.py index e1b12725..a63b6b2e 100644 --- a/python/tan/commands/validate_cmd.py +++ b/python/tan/commands/validate_cmd.py @@ -1097,35 +1097,51 @@ def _emit( if findings is None: findings = tuple(_Finding(issue.severity, issue.message) for issue in reportable) reported = list(zip(reportable, findings)) + + # Built ONCE, for every format (tan-cli#799): `Envelope.__init__` appends + # the tan-cli#407 `sdk.discovery-divergent` warning at the shared seam + # (`_with_sdk_divergence`). The three TEXT verdicts below loop `issues` + # directly, and used to loop the PRE-envelope list -- built only inside + # this JSON branch before -- so a seam-appended divergence issue reached + # `--format json` and stayed silent on the default channel. `data` is + # built unconditionally too (cheap, and only the JSON branch reads it): + # doing it here rather than after the rebind below would need `issues` + # computed twice for no reason. + data = { + "schemaVersion": DATA_SCHEMA_VERSION, + "outcome": outcome, + "issueCount": len(reported), + # The validator command line that actually ran, or `""` -- which + # every guard and the whole offline path keep, and which the two + # committed conformance fixtures pin. + "commandLine": command_line, + # `data.boardYamlPath` is UNTOUCHED by tan-cli#236 -- it names where + # tan looked even on the missing-board refusal below; only + # `project.boardYaml` (Rust's starkest instance of the bug: the + # refusal one line below says the file does not exist, in the same + # envelope that used to still name it) is existence-filtered. + "boardYamlPath": board_path, + } + envelope = Envelope( + "validate", + Project.resolved(root, board_path), + data, + issues, + exit_code, + # Absent, not null, when nothing resolved -- `Envelope` omits + # the key on `None`. See the module docstring's `sdk` block + # paragraph for the measured presence/absence matrix. + sdk=sdk, + ) + # Rebind: every branch below now sees whatever the seam appended. Safe + # against `reportable`/`reported`/`data["issueCount"]` above, all + # computed from the PRE-seam list -- a seam-appended issue's code always + # starts with "sdk.", so it was already excluded from `reportable` either + # way and none of those three needs recomputing. + issues = envelope.issues + if output_format == ValidateOutputFormat.JSON: - data = { - "schemaVersion": DATA_SCHEMA_VERSION, - "outcome": outcome, - "issueCount": len(reported), - # The validator command line that actually ran, or `""` -- which - # every guard and the whole offline path keep, and which the two - # committed conformance fixtures pin. - "commandLine": command_line, - # `data.boardYamlPath` is UNTOUCHED by tan-cli#236 -- it names where - # tan looked even on the missing-board refusal below; only - # `project.boardYaml` (Rust's starkest instance of the bug: the - # refusal one line below says the file does not exist, in the same - # envelope that used to still name it) is existence-filtered. - "boardYamlPath": board_path, - } - emit( - Envelope( - "validate", - Project.resolved(root, board_path), - data, - issues, - exit_code, - # Absent, not null, when nothing resolved -- `Envelope` omits - # the key on `None`. See the module docstring's `sdk` block - # paragraph for the measured presence/absence matrix. - sdk=sdk, - ) - ) + emit(envelope) elif output_format == ValidateOutputFormat.DIAGNOSTIC_V1: # indent=2, matching scripts/alp_cli/validate.py:34's # `json.dumps(to_machine_json(collector), indent=2)` -- these two diff --git a/python/tests/commands/test_sdk_discovery_ladders.py b/python/tests/commands/test_sdk_discovery_ladders.py index 2bfa9314..5bcc6e1a 100644 --- a/python/tests/commands/test_sdk_discovery_ladders.py +++ b/python/tests/commands/test_sdk_discovery_ladders.py @@ -535,3 +535,74 @@ def test_both_sides_of_the_split_warn_now_that_the_wide_callers_are_wired(tmp_pa for label, env in (("build", build_env), ("examples", examples_env)): codes = [i["code"] for i in env["issues"]] assert DIVERGENCE_CODE in codes, f"`tan {label}` stayed silent; codes were {codes}" + + +# ───────── the five narrow TEXT-channel callers, fixed (tan-cli#799) ───────── + +#: The shortest argv that reaches each command's post-envelope point in the +#: bare `_divergent_layout` fixture (no board.yaml, no `metadata/`, no +#: `system-manifest.yaml`) WITHOUT the run taking the internal-failure guard +#: -- that guard reports `sdk=None` on `clean`/`run`, and a `None` sdk can +#: never carry the divergence warning (`Envelope._with_sdk_divergence`'s own +#: gate). Each of these five resolves the SDK, then refuses on something else +#: entirely (a missing system-manifest.yaml, a missing board.yaml) -- exactly +#: the shape #799 measured: the refusal reaches text, the warning didn't. +NARROW_TEXT_COMMANDS: dict[str, tuple[str, ...]] = { + "size": ("size",), + "image": ("image",), + "clean": ("clean", "--dry-run"), + "run": ("run",), + "validate": ("validate",), +} + + +@pytest.mark.parametrize("command", sorted(NARROW_TEXT_COMMANDS)) +def test_narrow_text_channel_carries_the_divergence_warning(tmp_path, command): + """tan-cli#799: `size`/`image`/`clean`/`run`/`validate` each constructed + their `Envelope` only inside `if json_mode:` and rendered text from a + local `issues`/`text` list built strictly BEFORE that -- so + `sdk.discovery-divergent`, appended at the `Envelope.__init__` seam, + reached `--format json` and was silent on the default text channel. + `pinmux_cmd.py` was already correct (builds the envelope once, + unconditionally, and renders text from `envelope.issues`); this pins the + same fix on the other five, measured end to end rather than at the + seam/helper level the rest of this module already covers. + """ + workspace, child, lateral = _divergent_layout(tmp_path) + argv = NARROW_TEXT_COMMANDS[command] + + json_env = _envelope(_run_tan(*argv, "--format", "json", cwd=workspace)) + divergence = next((i for i in json_env["issues"] if i["code"] == DIVERGENCE_CODE), None) + assert divergence is not None, ( + f"the premise: `tan {' '.join(argv)} --format json` in the divergent " + f"layout must itself carry {DIVERGENCE_CODE}, or this test proves " + f"nothing about the text channel; issues were " + f"{[i['code'] for i in json_env['issues']]}" + ) + + text_proc = _run_tan(*argv, cwd=workspace) + assert "Traceback" not in text_proc.stderr, ( + f"an exception escaped the contract:\n{text_proc.stderr}" + ) + assert child.as_posix() in text_proc.stderr and lateral.as_posix() in text_proc.stderr, ( + f"`tan {' '.join(argv)}` (default/text) carried {DIVERGENCE_CODE} in " + f"its `--format json` envelope but not on the default text channel; " + f"stderr was:\n{text_proc.stderr!r}" + ) + + +def test_a_narrow_text_run_over_one_checkout_gets_no_divergence_warning(tmp_path): + """The regression the fix must not become: every ordinary single-SDK + workspace keeps a plain refusal on the text channel, with no collision + warning invented for it -- the same control + `test_a_workspace_with_one_checkout_gets_no_divergence_warning_from_tan_build` + already pins for `--format json`.""" + workspace = tmp_path / "ws" + workspace.mkdir() + _make_sdk(tmp_path / "alp-sdk") + + proc = _run_tan("size", cwd=workspace) + + assert "Traceback" not in proc.stderr + assert "discovery-divergent" not in proc.stderr + assert "two alp-sdk checkouts resolve" not in proc.stderr diff --git a/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md b/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md index a924c5e5..d99af30c 100644 --- a/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md +++ b/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md @@ -163,3 +163,7 @@ conflicts resolve by re-running a command instead of by hand-merging prose. - 2026-08-16 -- tan-cli#804: consume _teardown_sim's grace-loop poll (which already waits up to _QUIT_GRACE_S) as the sim-exited-early source at --timeout 0, discriminated via a surfaced quit() write-failure or a nonzero exit code so a healthy quit-driven shutdown is never misreported - tan/commands/renode_cmd.py: 1533 -> 1587 - function_count_budget: 260 -> 261 +- 2026-08-16 -- tan-cli#799: build the Envelope once, unconditionally, in size_cmd/clean_cmd/validate_cmd (and image_cmd/run_cmd, both still under the cap) so the sdk.discovery-divergent seam warning reaches text mode too, not only --format json + - tan/commands/clean_cmd.py: 1110 -> 1119 + - tan/commands/size_cmd.py: 831 -> 842 + - tan/commands/validate_cmd.py: 1571 -> 1587 From 8e12fcaf87b8a9f456d5008c409a581b618eb7a9 Mon Sep 17 00:00:00 2001 From: Caner Alp Date: Sun, 16 Aug 2026 19:00:00 +0000 Subject: [PATCH 2/6] fix(run): review round for tan-cli#799/#809 -- test --confirm wiring, close the help gap, scope the envelope build, pin the divergence text Adds the missing tests for #809's --confirm plumbing (run --flash --confirm reaches flash_cmd._run with confirm_flag=True, defaults to False when omitted, and --confirm is on run's --help), amends --flash's own help text to name the second opt-in it gates, scopes validate_cmd's now-unconditional Envelope/data construction to the TEXT/JSON branches so sarif/diagnostic-v1 stop paying for _with_sdk_divergence's filesystem walk, and pins the narrow-text-channel divergence assertion to a stable phrase plus the warning: severity prefix instead of just the two checkout paths. --- python/tan/commands/run_cmd.py | 5 +- python/tan/commands/validate_cmd.py | 82 ++++++++++--------- python/tests/commands/test_run_command.py | 58 ++++++++++++- .../commands/test_sdk_discovery_ladders.py | 20 ++++- python/tests/gates/MODULE_SIZE_BUDGET_LOG.md | 2 + 5 files changed, 125 insertions(+), 42 deletions(-) diff --git a/python/tan/commands/run_cmd.py b/python/tan/commands/run_cmd.py index bd0f7f15..c20e39c6 100644 --- a/python/tan/commands/run_cmd.py +++ b/python/tan/commands/run_cmd.py @@ -340,7 +340,10 @@ def run( "--flash", help="Program the board after building (hardware targets only). Required " "opt-in: without it, `run` on a hardware project builds and reports but " - "never flashes. Ignored for a native_sim/host target, which always runs " + "never flashes. Arming the write also needs --confirm (or " + "ALP_FLASH_FORCE=1, or flash_args.confirm: true in the manifest); " + "without it every slice comes back `planned` and the run exits " + "non-zero. Ignored for a native_sim/host target, which always runs " "the produced binary and never flashes.", ), core: str = typer.Option( diff --git a/python/tan/commands/validate_cmd.py b/python/tan/commands/validate_cmd.py index a63b6b2e..64d97576 100644 --- a/python/tan/commands/validate_cmd.py +++ b/python/tan/commands/validate_cmd.py @@ -1098,47 +1098,51 @@ def _emit( findings = tuple(_Finding(issue.severity, issue.message) for issue in reportable) reported = list(zip(reportable, findings)) - # Built ONCE, for every format (tan-cli#799): `Envelope.__init__` appends - # the tan-cli#407 `sdk.discovery-divergent` warning at the shared seam + # Built for TEXT/JSON only (tan-cli#799): `Envelope.__init__` appends the + # tan-cli#407 `sdk.discovery-divergent` warning at the shared seam # (`_with_sdk_divergence`). The three TEXT verdicts below loop `issues` # directly, and used to loop the PRE-envelope list -- built only inside - # this JSON branch before -- so a seam-appended divergence issue reached - # `--format json` and stayed silent on the default channel. `data` is - # built unconditionally too (cheap, and only the JSON branch reads it): - # doing it here rather than after the rebind below would need `issues` - # computed twice for no reason. - data = { - "schemaVersion": DATA_SCHEMA_VERSION, - "outcome": outcome, - "issueCount": len(reported), - # The validator command line that actually ran, or `""` -- which - # every guard and the whole offline path keep, and which the two - # committed conformance fixtures pin. - "commandLine": command_line, - # `data.boardYamlPath` is UNTOUCHED by tan-cli#236 -- it names where - # tan looked even on the missing-board refusal below; only - # `project.boardYaml` (Rust's starkest instance of the bug: the - # refusal one line below says the file does not exist, in the same - # envelope that used to still name it) is existence-filtered. - "boardYamlPath": board_path, - } - envelope = Envelope( - "validate", - Project.resolved(root, board_path), - data, - issues, - exit_code, - # Absent, not null, when nothing resolved -- `Envelope` omits - # the key on `None`. See the module docstring's `sdk` block - # paragraph for the measured presence/absence matrix. - sdk=sdk, - ) - # Rebind: every branch below now sees whatever the seam appended. Safe - # against `reportable`/`reported`/`data["issueCount"]` above, all - # computed from the PRE-seam list -- a seam-appended issue's code always - # starts with "sdk.", so it was already excluded from `reportable` either - # way and none of those three needs recomputing. - issues = envelope.issues + # the JSON branch before -- so a seam-appended divergence issue reached + # `--format json` and stayed silent on the default channel. DIAGNOSTIC_V1 + # and SARIF are ported documents that read neither `data` nor `envelope` + # (see the two branches below), so building either for them would only + # pay for `_with_sdk_divergence`'s filesystem ladder walk and discard the + # result -- skip both there. + if output_format in (ValidateOutputFormat.JSON, ValidateOutputFormat.TEXT): + data = { + "schemaVersion": DATA_SCHEMA_VERSION, + "outcome": outcome, + "issueCount": len(reported), + # The validator command line that actually ran, or `""` -- which + # every guard and the whole offline path keep, and which the two + # committed conformance fixtures pin. + "commandLine": command_line, + # `data.boardYamlPath` is UNTOUCHED by tan-cli#236 -- it names + # where tan looked even on the missing-board refusal below; only + # `project.boardYaml` (Rust's starkest instance of the bug: the + # refusal one line below says the file does not exist, in the + # same envelope that used to still name it) is + # existence-filtered. + "boardYamlPath": board_path, + } + envelope = Envelope( + "validate", + Project.resolved(root, board_path), + data, + issues, + exit_code, + # Absent, not null, when nothing resolved -- `Envelope` omits + # the key on `None`. See the module docstring's `sdk` block + # paragraph for the measured presence/absence matrix. + sdk=sdk, + ) + # Rebind: every branch below now sees whatever the seam appended. + # Safe against `reportable`/`reported`/`data["issueCount"]` above, + # all computed from the PRE-seam list -- a seam-appended issue's + # code always starts with "sdk.", so it was already excluded from + # `reportable` either way and none of those three needs + # recomputing. + issues = envelope.issues if output_format == ValidateOutputFormat.JSON: emit(envelope) diff --git a/python/tests/commands/test_run_command.py b/python/tests/commands/test_run_command.py index 7e0e4bac..ab379be4 100644 --- a/python/tests/commands/test_run_command.py +++ b/python/tests/commands/test_run_command.py @@ -66,7 +66,9 @@ def test_run_is_not_an_alias_for_build_or_flash(): def test_run_help_lists_its_own_flags_not_builds_or_flashs(): result = CliRunner().invoke(_app(), ["run", "--help"]) assert result.exit_code == 0, result.output - for flag in ("--flash", "--core", "--project", "--board-yaml", "--sdk-root", "--format"): + for flag in ( + "--flash", "--core", "--confirm", "--project", "--board-yaml", "--sdk-root", "--format", + ): assert flag in result.output, result.output # `build`-only flags (crates/tan-cli/src/cli.rs BuildArgs) must not leak in. for flag in ("--plan", "--materialise", "--native", "--manifest", "--pristine", @@ -193,6 +195,60 @@ def fake_flash_run(**kwargs): assert text == ["flash: 0 failure(s)."] +def test_internal_run_flash_forwards_confirm_true_to_the_flash_engine(tmp_path, monkeypatch): + """tan-cli#809: `run --flash --confirm` must arm `flash_cmd._run`'s own + confirm gate (`confirm_flag=True`), the same opt-in `tan flash --confirm` + already has -- otherwise every slice comes back `planned` and `run` + exits non-zero with `flash.nothing-flashed` even though the caller asked + to write the device.""" + monkeypatch.setattr(run_cmd, "_build", _stub_build) + monkeypatch.setattr(run_cmd, "decide_run_action", lambda *a, **k: RunAction.FLASH) + calls = {} + + def fake_flash_run(**kwargs): + calls.update(kwargs) + return ( + ExitCode.SUCCESS, + {"schemaVersion": "1", "buildRoot": kwargs["app_path"], "entries": []}, + [], + ["flash: 0 failure(s)."], + None, + ) + + monkeypatch.setattr(flash_cmd, "_run", fake_flash_run) + run_cmd._run( + build_root=str(tmp_path), sdk_root=None, sdk_root_for_stamp=None, board_yaml=None, + flash=True, core=None, json_mode=False, confirm=True, + ) + assert calls["confirm_flag"] is True + + +def test_internal_run_flash_defaults_confirm_false_when_omitted(tmp_path, monkeypatch): + """The companion of the above: an ordinary `run --flash` (no `--confirm`) + must NOT silently arm the write -- `confirm_flag` reaches `flash_cmd._run` + as `False`, preserving the preview-only default `tan flash` itself has.""" + monkeypatch.setattr(run_cmd, "_build", _stub_build) + monkeypatch.setattr(run_cmd, "decide_run_action", lambda *a, **k: RunAction.FLASH) + calls = {} + + def fake_flash_run(**kwargs): + calls.update(kwargs) + return ( + ExitCode.SUCCESS, + {"schemaVersion": "1", "buildRoot": kwargs["app_path"], "entries": []}, + [], + ["flash: 0 failure(s)."], + None, + ) + + monkeypatch.setattr(flash_cmd, "_run", fake_flash_run) + run_cmd._run( + build_root=str(tmp_path), sdk_root=None, sdk_root_for_stamp=None, board_yaml=None, + flash=True, core=None, json_mode=False, + ) + assert calls["confirm_flag"] is False + + def test_internal_run_reaches_flash_via_the_real_recorded_signal_not_a_stub(tmp_path, monkeypatch): """The wiring this unit exists for: `--core`/`--flash` reach the flash engine driven by the REAL `tan.commands.build.execute.last_manifest_write` diff --git a/python/tests/commands/test_sdk_discovery_ladders.py b/python/tests/commands/test_sdk_discovery_ladders.py index 5bcc6e1a..6e678d0d 100644 --- a/python/tests/commands/test_sdk_discovery_ladders.py +++ b/python/tests/commands/test_sdk_discovery_ladders.py @@ -50,6 +50,14 @@ DIVERGENCE_CODE = "sdk.discovery-divergent" +#: A stable substring of `sdk_ladder_divergence_issue`'s own message +#: (`build_cmd.py`'s `sdk_ladder_divergence_issue`), pinned the same way +#: `E2E_DIVERGENCE_PHRASE` below pins doctor's -- so a reword that keeps both +#: checkout paths in the text but drops the sentence naming what they mean +#: (or drops the "warning:" severity the text renderer prepends) still fails +#: this file instead of silently passing. +DIVERGENCE_TEXT_PHRASE = 'both report sourceTier "discovery"' + def _make_sdk(root: Path) -> Path: """The one marker every discovery tier keys on (`SDK_MARKER`). No @@ -584,7 +592,17 @@ def test_narrow_text_channel_carries_the_divergence_warning(tmp_path, command): assert "Traceback" not in text_proc.stderr, ( f"an exception escaped the contract:\n{text_proc.stderr}" ) - assert child.as_posix() in text_proc.stderr and lateral.as_posix() in text_proc.stderr, ( + # Both checkout paths AND the stable phrase/severity prefix that make + # them mean something -- a reword that dropped the "warning:" severity + # or the sentence explaining what the two paths are (while still, by + # coincidence, printing both paths somewhere in stderr) must fail this, + # not silently pass it. Same reasoning as `E2E_DIVERGENCE_PHRASE` above. + assert ( + child.as_posix() in text_proc.stderr + and lateral.as_posix() in text_proc.stderr + and "warning:" in text_proc.stderr + and DIVERGENCE_TEXT_PHRASE in text_proc.stderr + ), ( f"`tan {' '.join(argv)}` (default/text) carried {DIVERGENCE_CODE} in " f"its `--format json` envelope but not on the default text channel; " f"stderr was:\n{text_proc.stderr!r}" diff --git a/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md b/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md index d99af30c..e8e2caa7 100644 --- a/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md +++ b/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md @@ -167,3 +167,5 @@ conflicts resolve by re-running a command instead of by hand-merging prose. - tan/commands/clean_cmd.py: 1110 -> 1119 - tan/commands/size_cmd.py: 831 -> 842 - tan/commands/validate_cmd.py: 1571 -> 1587 +- 2026-08-16 -- tan-cli#799 review: scope validate_cmd's Envelope construction to TEXT/JSON only (nit fix) + - tan/commands/validate_cmd.py: 1587 -> 1591 From 3ff15f78a9a40bc67dc33be984ad1f9eeb3f92b4 Mon Sep 17 00:00:00 2001 From: Caner Alp Date: Sun, 16 Aug 2026 19:52:04 +0000 Subject: [PATCH 3/6] fix(cli): prefix validate's sdk.* text with severity, and pin --confirm on the parser (tan-cli#799/#809) --- python/tan/commands/validate_cmd.py | 30 +++++++++++++-- python/tests/commands/test_run_command.py | 39 ++++++++++++++++++++ python/tests/gates/MODULE_SIZE_BUDGET_LOG.md | 2 + 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/python/tan/commands/validate_cmd.py b/python/tan/commands/validate_cmd.py index 64d97576..5c05e485 100644 --- a/python/tan/commands/validate_cmd.py +++ b/python/tan/commands/validate_cmd.py @@ -1172,9 +1172,18 @@ def _emit( # OUT of `issueCount`/sarif/diagnostic-v1 still belong on the # customer's screen; the other two branches below already print # them because they loop over the unfiltered `issues`. + # + # tan-cli#799: severity-prefixed (`f"{issue.severity}: ..."`), + # matching how `clean`/`size`/`image`/`run` render the identical + # `sdk.*` seam issues -- a bare `issue.message` here was the one + # text-channel divergence `test_narrow_text_channel_carries_the_ + # divergence_warning` measured (no "warning:" prefix on + # `sdk.discovery-divergent`). `reportable[0].message` just below + # stays bare on purpose: that line is validate's own verdict + # wording (#350/#498), never an `sdk.*` advisory. for issue in issues: if issue.code.startswith("sdk."): - stream.write(f"{issue.message}\n") + stream.write(f"{issue.severity}: {issue.message}\n") stream.write(f"{reportable[0].message}\n") elif len(reportable) == 1 and reportable[0].code == "validate.board-yaml-unreadable": # tan-cli#498 defect 3, the text half, and the same reasoning as @@ -1191,12 +1200,20 @@ def _emit( stream.write("validate: board.yaml could not be read\n") for issue in issues: if issue.code.startswith("sdk."): - stream.write(f"{issue.message}\n") + stream.write(f"{issue.severity}: {issue.message}\n") stream.write(f"{reportable[0].message}\n") elif outcome != OUTCOME_CLEAN: stream.write("validate: validation failure\n") + # tan-cli#799: `sdk.*` seam issues (e.g. `sdk.discovery-divergent`) + # get the same `f"{severity}: {message}"` rendering the other + # four narrow-text commands use; validate's own `reportable` + # findings (already accounted for in `outcome`/`issueCount` above + # this render block) keep their bare wording unchanged. for issue in issues: - stream.write(f"{issue.message}\n") + if issue.code.startswith("sdk."): + stream.write(f"{issue.severity}: {issue.message}\n") + else: + stream.write(f"{issue.message}\n") else: # Keyed off the OUTCOME, not off `issues` being non-empty: a # SPAWNED validator that exits 0 having printed warnings @@ -1208,8 +1225,13 @@ def _emit( # "validate: validation failure" over exit 0. The oracle's own # `spawn_text` keys off the outcome for exactly this reason. stream.write(f"validate: {board_path} is clean\n") + # Same tan-cli#799 severity-prefix rule as the `validation + # failure` branch above: only the `sdk.*` seam issues get it. for issue in issues: - stream.write(f"{issue.message}\n") + if issue.code.startswith("sdk."): + stream.write(f"{issue.severity}: {issue.message}\n") + else: + stream.write(f"{issue.message}\n") raise typer.Exit(int(exit_code)) diff --git a/python/tests/commands/test_run_command.py b/python/tests/commands/test_run_command.py index ab379be4..393eb414 100644 --- a/python/tests/commands/test_run_command.py +++ b/python/tests/commands/test_run_command.py @@ -94,6 +94,45 @@ def test_run_reports_build_failed_when_no_sdk_found(tmp_path, monkeypatch): assert payload["issues"][0]["code"] == "build.plan-unavailable" +def test_run_parser_actually_accepts_confirm(): + """tan-cli#799 review: `test_run_help_lists_its_own_flags_not_builds_or_ + flashs` asserted `"--confirm" in result.output`, but `--flash`'s own help + text (`run_cmd.py`'s `flash` option) happens to CONTAIN the literal + substring "--confirm" -- so that assertion stayed green even with the + `confirm` option removed entirely from `run`'s signature (measured: see + the module docstring below). Assert on the PARSER instead: the Click + command's registered `params` must contain an option whose `opts` include + `--confirm`, which is only true when the option is actually declared. + """ + from typer.main import get_command + + click_command = get_command(_app()) + run_click_command = click_command.get_command(None, "run") + # Not `isinstance(param, click.Option)`: this Typer version vendors its + # own internal Click fork (`typer._click`), so `TyperOption` does NOT + # subclass the real, separately-installed `click` package's `Option` -- + # `param.opts` (a plain list every Click/Typer parameter exposes) is the + # portable check. + confirm_params = [ + param for param in run_click_command.params if "--confirm" in getattr(param, "opts", ()) + ] + assert confirm_params, ( + f"no --confirm option registered on `run`; opts were " + f"{[getattr(p, 'opts', None) for p in run_click_command.params]}" + ) + + +def test_run_confirm_flag_is_accepted_by_the_real_parser(tmp_path, monkeypatch): + """Same proof, end to end: invoking `run --flash --confirm` must never + hit Click's own "No such option" refusal. Combined with the forwarding + tests below (which call `run_cmd._run(confirm=True)` directly), this + closes the gap between "the flag parses" and "the flag is wired + through".""" + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke(_app(), ["run", "--flash", "--confirm", "--format", "json"]) + assert "No such option" not in result.output, result.output + + def test_run_help_text_mode_reaches_a_command_error_free(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) result = CliRunner().invoke(_app(), ["run"]) diff --git a/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md b/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md index e8e2caa7..66b01a2d 100644 --- a/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md +++ b/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md @@ -169,3 +169,5 @@ conflicts resolve by re-running a command instead of by hand-merging prose. - tan/commands/validate_cmd.py: 1571 -> 1587 - 2026-08-16 -- tan-cli#799 review: scope validate_cmd's Envelope construction to TEXT/JSON only (nit fix) - tan/commands/validate_cmd.py: 1587 -> 1591 +- 2026-08-16 -- tan-cli#799: validate's sdk.* seam-issue text now carries a severity prefix, matching clean/size/image/run + - tan/commands/validate_cmd.py: 1591 -> 1613 From 4601464644cd1e5143df95d9c801c8e341e83ecc Mon Sep 17 00:00:00 2001 From: Caner Alp Date: Sun, 16 Aug 2026 20:31:47 +0000 Subject: [PATCH 4/6] fix(changelog): relocate 799/809 fragments to the repo-root changelog.d/ --- {python/changelog.d => changelog.d}/799.fixed.md | 0 {python/changelog.d => changelog.d}/809.fixed.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {python/changelog.d => changelog.d}/799.fixed.md (100%) rename {python/changelog.d => changelog.d}/809.fixed.md (100%) diff --git a/python/changelog.d/799.fixed.md b/changelog.d/799.fixed.md similarity index 100% rename from python/changelog.d/799.fixed.md rename to changelog.d/799.fixed.md diff --git a/python/changelog.d/809.fixed.md b/changelog.d/809.fixed.md similarity index 100% rename from python/changelog.d/809.fixed.md rename to changelog.d/809.fixed.md From 47cbd715e599c3c5b5739039ff1e8dcfa4cce44e Mon Sep 17 00:00:00 2001 From: Caner Alp Date: Mon, 17 Aug 2026 00:22:06 +0000 Subject: [PATCH 5/6] chore(gates): regenerate module_size_budget after rebase onto origin/dev --- python/tests/gates/MODULE_SIZE_BUDGET_LOG.md | 4 ++++ python/tests/gates/module_size_budget.generated.json | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md b/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md index 66b01a2d..fa288b2f 100644 --- a/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md +++ b/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md @@ -171,3 +171,7 @@ conflicts resolve by re-running a command instead of by hand-merging prose. - tan/commands/validate_cmd.py: 1587 -> 1591 - 2026-08-16 -- tan-cli#799: validate's sdk.* seam-issue text now carries a severity prefix, matching clean/size/image/run - tan/commands/validate_cmd.py: 1591 -> 1613 +- 2026-08-17 -- merge-resync (growth already reasoned on the merged branches) + - tan/commands/clean_cmd.py: 1110 -> 1119 + - tan/commands/size_cmd.py: 831 -> 842 + - tan/commands/validate_cmd.py: 1571 -> 1613 diff --git a/python/tests/gates/module_size_budget.generated.json b/python/tests/gates/module_size_budget.generated.json index e8430fa8..301b8ada 100644 --- a/python/tests/gates/module_size_budget.generated.json +++ b/python/tests/gates/module_size_budget.generated.json @@ -7,7 +7,7 @@ "tan/commands/bootstrap_cmd.py": 3286, "tan/commands/build/execute.py": 1662, "tan/commands/build_cmd.py": 2172, - "tan/commands/clean_cmd.py": 1110, + "tan/commands/clean_cmd.py": 1119, "tan/commands/debug_config_cmd.py": 1949, "tan/commands/diff_cmd.py": 882, "tan/commands/doctor_cmd.py": 4035, @@ -18,9 +18,9 @@ "tan/commands/new_som_cmd.py": 1361, "tan/commands/renode_cmd.py": 1587, "tan/commands/sdk_cmd.py": 1415, - "tan/commands/size_cmd.py": 831, + "tan/commands/size_cmd.py": 842, "tan/commands/support_bundle_cmd.py": 1066, - "tan/commands/validate_cmd.py": 1571, + "tan/commands/validate_cmd.py": 1613, "tan/core/bootstrap.py": 2400, "tan/core/debug_launch.py": 1275, "tan/core/flash_plan.py": 3199, From c8bfddf73384954f0a71d8ab88bc7a15fdb68b0a Mon Sep 17 00:00:00 2001 From: Caner Alp Date: Mon, 17 Aug 2026 01:13:29 +0000 Subject: [PATCH 6/6] fix(gates): drop the redundant merge-resync ledger line from the previous rebase pass The previous rebase's merge-resync entry double-counted growth in clean_cmd.py/size_cmd.py/validate_cmd.py that is already fully accounted for by this branch's own three tan-cli#799 ledger entries directly above it. origin/dev never touches those three modules, so there was no genuine merge growth left to resync; the line was a stale artifact of the earlier rebase pass, not a real regen event. --- python/tests/gates/MODULE_SIZE_BUDGET_LOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md b/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md index fa288b2f..66b01a2d 100644 --- a/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md +++ b/python/tests/gates/MODULE_SIZE_BUDGET_LOG.md @@ -171,7 +171,3 @@ conflicts resolve by re-running a command instead of by hand-merging prose. - tan/commands/validate_cmd.py: 1587 -> 1591 - 2026-08-16 -- tan-cli#799: validate's sdk.* seam-issue text now carries a severity prefix, matching clean/size/image/run - tan/commands/validate_cmd.py: 1591 -> 1613 -- 2026-08-17 -- merge-resync (growth already reasoned on the merged branches) - - tan/commands/clean_cmd.py: 1110 -> 1119 - - tan/commands/size_cmd.py: 831 -> 842 - - tan/commands/validate_cmd.py: 1571 -> 1613