diff --git a/.console/log.md b/.console/log.md index 861923c..5854b4b 100644 --- a/.console/log.md +++ b/.console/log.md @@ -1,4 +1,46 @@ # Log +## 2026-08-03 — fix(cli): stop console encoding from failing a command that succeeded + +`cl reconcile check` computed a GREEN verdict, then died printing it: + + UnicodeEncodeError: 'charmap' codec can't encode character '→' + +`check.py:132` renders cross-repo routing with `→`, and a default Windows +console is cp1252. So any worksheet carrying a cross-repo item — the ordinary +case, since routing work to its owning repo is the point — reported a passing +gate as a traceback. The check had already finished; only the formatting failed. + +Fixed at the stream, not the glyph. Replacing `→` would have been whack-a-mole: +the source carries nine distinct non-ASCII codepoints across ~940 occurrences, +with output-bearing lines in `cli/ledger.py`, `reconcile/check.py` and +`cli/loop.py`, and any new report line could reintroduce it. `ensure_printable_ +console()` prefers UTF-8 and falls back to `errors="replace"`, so output degrades +to `?` instead of raising. Installed as a Typer root callback, which runs before +every subcommand and takes no options, so the CLI surface is unchanged. + +Same fix and near-identical wording as Custodian's `cli/colors.py`, which hit +this in its verbose audit report. Duplicated rather than shared — CL does not +depend on Custodian, and it is fifteen lines. + +The fallback branch is not theoretical: a stream whose buffer is detached +rejects an encoding change but still accepts an errors change, so the guard +retries with errors alone rather than giving up on not-raising. + +8 tests in `tests/test_console.py`. The first asserts the cp1252 stream really +does reject the report glyphs — without it the other seven could pass against a +stream that was never capable of failing. Suite 449 -> 457 passed, the 25 +pre-existing failures and the 2 `cryptography` collection errors unchanged. + +Custodian's audit caught two things in the first draft, both fixed: T7 (the file +was `test_console_encoding.py`, so `cli/console.py` had no parallel test) and T2 +(the skip-path test asserted nothing — it now writes through the stream and +checks the content, so "skipping is a no-op" is actually verified rather than +merely not crashing). Audit is back to 0 findings. + +Note for anyone reading CI here: `Lint (ruff)` is red on this branch and was +already red on `main` — 204 findings, identical count before and after this +change, none in the files touched here. + ## 2026-07-17 — docs: D3 P5 — record stopped_logged_violation as spec-deferred Resolved P5 (the last D3 phase) as a **decision, not a build**. Investigated diff --git a/src/context_lifecycle/cli/console.py b/src/context_lifecycle/cli/console.py new file mode 100644 index 0000000..d74562b --- /dev/null +++ b/src/context_lifecycle/cli/console.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +"""Console-encoding guard for the `cl` CLI. + +Formatting must never be able to fail a command that already succeeded. +""" + +from __future__ import annotations + +import sys + + +def ensure_printable_console() -> None: + """Stop console encoding from crashing a command that already did its work. + + Windows consoles default to cp1252, which cannot encode the arrows, box + drawing, section signs and em-dashes this CLI's reports use. Printing them + raises ``UnicodeEncodeError`` *after* the command has finished, discarding + the result the operator asked for and exiting non-zero — a green check + reported as a failure. + + Concretely: ``cl reconcile check`` on a worksheet containing any cross-repo + item died on the ``->`` glyph in its routing line, having already computed a + GREEN verdict. The gate passed; the operator saw a traceback. + + Replacing glyphs one at a time does not hold — the source carries nine + distinct non-ASCII codepoints across ~940 occurrences, and any new report + line can reintroduce the crash. Fixing the stream instead makes the whole + class impossible: prefer UTF-8, and fall back to replacing unencodable + characters so output degrades to ``?`` rather than raising. + + Idempotent, and safe to call on non-standard streams (pytest's capture + objects have no ``reconfigure``); those are skipped. + """ + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: # pytest capture, io.StringIO, closed stream + continue + try: + reconfigure(encoding="utf-8", errors="replace") + except (ValueError, OSError, LookupError): # pragma: no cover + # Stream refuses a full reconfigure (detached buffer, exotic + # terminal). Salvage what matters: never raise on an unencodable + # character, even if the encoding itself cannot be changed. + try: + reconfigure(errors="replace") + except (ValueError, OSError): + pass diff --git a/src/context_lifecycle/cli/main.py b/src/context_lifecycle/cli/main.py index 45a62fb..7d632f3 100644 --- a/src/context_lifecycle/cli/main.py +++ b/src/context_lifecycle/cli/main.py @@ -12,6 +12,7 @@ from context_lifecycle.cli import loop as loop_cmd from context_lifecycle.cli import reconcile as reconcile_cmd from context_lifecycle.cli import session as session_cmd +from context_lifecycle.cli.console import ensure_printable_console app = typer.Typer( name="cl", @@ -20,6 +21,19 @@ add_completion=False, ) +@app.callback() +def _root() -> None: + """Runs before every subcommand. + + Only job is the console-encoding guard: reports carry non-ASCII glyphs that + a cp1252 Windows console cannot encode, and the resulting UnicodeEncodeError + fires *after* the command's real work is done. Installed here rather than in + each command so no future report line can reintroduce the crash. Takes no + options, so the CLI surface is unchanged. + """ + ensure_printable_console() + + app.add_typer(hook_cmd.app, name="hook", help="Claude Code hook adapters (pre_tool_use, stop).") app.add_typer(session_cmd.app, name="session", help="Session anchor lifecycle (start, show, end).") app.add_typer(context_cmd.app, name="context", help="Session-boundary cognition (hydrate, capture, peek) for non-hook CLIs.") diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..668a99b --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +"""Tests for the console-encoding guard. + +Regression cover for: `cl reconcile check` computed a GREEN verdict, then died +with UnicodeEncodeError printing the `->` glyph in its cross-repo routing line +on a cp1252 Windows console. The gate passed; the operator saw a traceback and +a non-zero exit. +""" + +from __future__ import annotations + +import io +import sys + +import pytest +from typer.testing import CliRunner + +from context_lifecycle.cli.console import ensure_printable_console +from context_lifecycle.cli.main import app + +# One of each non-ASCII codepoint the CLI's report lines actually use. +REPORT_GLYPHS = "→ ⇒ — ─ § … ≥ ✓ ✗" + + +class _Cp1252Stream(io.TextIOBase): + """A stream that encodes as cp1252 — i.e. a default Windows console. + + `reconfigure` mutates the declared encoding/errors the way a real + TextIOWrapper does, so the guard can be observed taking effect. + """ + + def __init__(self) -> None: + super().__init__() + self._encoding = "cp1252" + self._errors = "strict" + self.written: list[str] = [] + + @property + def encoding(self) -> str: + return self._encoding + + @property + def errors(self) -> str: + return self._errors + + def reconfigure(self, *, encoding: str | None = None, errors: str | None = None) -> None: + if encoding is not None: + self._encoding = encoding + if errors is not None: + self._errors = errors + + def write(self, s: str) -> int: + # Mimic a real console: encoding happens at write time and raises + # under 'strict', which is exactly how the original crash surfaced. + s.encode(self._encoding, self._errors) + self.written.append(s) + return len(s) + + +def test_cp1252_stream_rejects_report_glyphs_before_the_guard(): + """Without the guard the failure is real — otherwise this suite proves nothing.""" + stream = _Cp1252Stream() + with pytest.raises(UnicodeEncodeError): + stream.write(REPORT_GLYPHS) + + +def test_guard_makes_report_glyphs_printable(monkeypatch): + stream = _Cp1252Stream() + monkeypatch.setattr(sys, "stdout", stream) + ensure_printable_console() + stream.write(REPORT_GLYPHS) + assert stream.written == [REPORT_GLYPHS] + + +def test_guard_reconfigures_both_streams(monkeypatch): + out, err = _Cp1252Stream(), _Cp1252Stream() + monkeypatch.setattr(sys, "stdout", out) + monkeypatch.setattr(sys, "stderr", err) + ensure_printable_console() + for stream in (out, err): + assert stream.encoding == "utf-8" + assert stream.errors == "replace" + + +def test_guard_falls_back_to_errors_only_when_encoding_is_refused(monkeypatch): + """A stream with a detached buffer rejects an encoding change. + + Never raising on an unencodable character still matters, so the guard must + retry with errors alone rather than give up. + """ + + class _RefusesEncoding(_Cp1252Stream): + def reconfigure(self, *, encoding=None, errors=None): + if encoding is not None: + raise ValueError("cannot change encoding of a detached stream") + super().reconfigure(errors=errors) + + stream = _RefusesEncoding() + monkeypatch.setattr(sys, "stdout", stream) + ensure_printable_console() + assert stream.encoding == "cp1252" # unchanged — the refusal stood + assert stream.errors == "replace" # but output can no longer raise + stream.write(REPORT_GLYPHS) # would raise under 'strict' + + +def test_guard_skips_streams_without_reconfigure(monkeypatch): + """pytest capture objects and StringIO have no reconfigure; must not raise. + + Asserts the stream stays usable afterwards rather than just that the call + returned — skipping must be a no-op, not a half-applied change. + """ + out = io.StringIO() + monkeypatch.setattr(sys, "stdout", out) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + ensure_printable_console() + out.write(REPORT_GLYPHS) + assert out.getvalue() == REPORT_GLYPHS + + +def test_guard_is_idempotent(monkeypatch): + stream = _Cp1252Stream() + monkeypatch.setattr(sys, "stdout", stream) + ensure_printable_console() + ensure_printable_console() + assert stream.encoding == "utf-8" + assert stream.errors == "replace" + + +def test_root_callback_runs_the_guard(monkeypatch): + """The guard must be wired to the app, not merely importable.""" + calls: list[int] = [] + monkeypatch.setattr( + "context_lifecycle.cli.main.ensure_printable_console", + lambda: calls.append(1), + ) + CliRunner().invoke(app, ["reconcile", "--help"]) + assert calls, "root callback did not invoke the console guard" + + +def test_cli_surface_is_unchanged_by_the_callback(): + """The callback takes no options, so `cl --help` still lists every command.""" + result = CliRunner().invoke(app, ["--help"]) + assert result.exit_code == 0 + for name in ("hook", "session", "context", "reconcile", "ledger", "loop"): + assert name in result.output