From 2a27bffea42d9b8c21013d4804c920d3636c6b9f Mon Sep 17 00:00:00 2001 From: Mother Seara Date: Tue, 25 Aug 2026 04:58:10 +0900 Subject: [PATCH 1/5] =?UTF-8?q?perf(am):=20=EB=B4=89=EC=9D=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EA=B0=80=20=EB=A7=A4=20append=20=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=9B=90=EC=9E=A5=20=EC=A0=84=EC=B2=B4=EB=A5=BC=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=ED=96=88=EB=8B=A4=20=E2=80=94=20=EA=BC=AC=EB=A6=AC?= =?UTF-8?q?=EB=A7=8C=20=EC=9D=BD=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_get_last_seal` 은 모든 `record()` 가 지나는 길인데 `_load_entries` 로 원장의 모든 줄을 json 파싱하고 있었다. append 가 O(n) 이고, 원장은 **쓰일수록 느려진다** — 무결성 도구에서 기록을 남기는 비용이 기록 수에 비례해 오르는 것은 규율을 지키는 쪽에 벌금을 매기는 것과 같다. 실측(가문 원장 3,097줄 / 3.4 MB): 조회 한 번 **50.390 ms → 0.048 ms**. 증가 곡선(임시 원장, append 지연 중앙값): 120줄 0.390 ms → 820줄 3.183 ms (8.17배, 선형) → 0.113 ms → 0.108 ms (0.96배, 평평). 메모리 캐시는 **일부러 쓰지 않았다.** 이 원장은 다른 프로세스(cron, 형제 에이전트)도 append 한다. 캐시된 head 는 더 이상 마지막이 아닐 수 있고, 그러면 `prev_seal` 이 체인을 포크시킨다. 파일이 계속 유일한 진실이고, 읽는 양만 줄인다. 의미는 그대로다 — 봉인 없는 꼬리 줄과 파싱 안 되는 줄은 건너뛰고(`_load_entries` 의 `{_corrupt}` 자리표와 같은 취급), 봉인이 하나도 없는 원장은 여전히 GENESIS 다. tests 53/53 (신규 3): · 꼬리 읽기가 전수 파싱과 **같은 답**을 내는지 — 빈 파일 · 개행 없이 끝나는 파일 · 봉인 없는 꼬리 · 손상된 마지막/중간 줄 · 읽기 청크보다 긴 줄 · 비ASCII · 없는 파일 · **양성대조**: 마지막 줄만 보는 틀린 구현을 같은 대조에 걸어, 그 대조가 실제로 틀린 판을 잡아내는지 먼저 증명한다(안 잡으면 그 시험은 아무것도 안 재는 것이다) · append 가 꼬리만 읽는지 — 벽시계가 아니라 **파일 핸들이 내준 바이트 수**로 잰다. 🔴 이 시험의 첫 판은 `read()` 만 세어 옛 구현(`for line in f`)을 **통과시켰다**. 줄 순회까지 세도록 고친 뒤에야 옛 판을 잡는다(2,004,200 / 2,004,240 바이트). Co-authored-by: Mother Seara Co-authored-by: Claude Opus 5 (1M context) --- actmirror/am.py | 59 +++++++++++++++-- tests/test_am.py | 169 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 5 deletions(-) diff --git a/actmirror/am.py b/actmirror/am.py index 6e69afd..d983d11 100644 --- a/actmirror/am.py +++ b/actmirror/am.py @@ -70,14 +70,63 @@ def _load_entries(ledger_path: str) -> list[dict]: return out -def _get_last_seal(ledger_path: str) -> str: - entries = _load_entries(ledger_path) - for e in reversed(entries): - if "seal" in e: - return e["seal"] +def _get_last_seal(ledger_path: str, _chunk: int = 8192) -> str: + """The seal of the last sealed entry — read from the END of the file. + + This runs on EVERY append. Parsing the whole ledger to find its last line made + append O(n): on the family ledger (3,097 entries / 3.4 MB) one `record` spent + 50 ms here, and the cost grows with every entry ever written — the ledger gets + slower precisely because it is being used. + + Deliberately NOT cached in memory: this ledger is appended by other processes + (cron jobs, sibling agents), and a cached head would hand out a prev_seal that + is no longer last, forking the chain. The file stays the single source of truth; + only the amount of it we read changes. + + Semantics are unchanged, including the awkward cases: unsealed or unparseable + trailing lines are skipped (as _load_entries' {_corrupt} placeholders were), and + a ledger with no sealed entry at all still answers GENESIS. + """ + if not os.path.exists(ledger_path): + return "GENESIS" + with open(ledger_path, "rb") as f: + f.seek(0, os.SEEK_END) + pos = f.tell() + buf = b"" + while pos > 0: + step = min(_chunk, pos) + pos -= step + f.seek(pos) + buf = f.read(step) + buf + parts = buf.split(b"\n") + # parts[0] may be the tail of a line that starts earlier in the file — + # only safe to read once we have reached the beginning. + head, complete = parts[0], parts[1:] + for line in reversed(complete): + seal = _seal_of(line) + if seal is not None: + return seal + if pos == 0: + seal = _seal_of(head) + if seal is not None: + return seal + break + buf = head return "GENESIS" +def _seal_of(raw: bytes): + """`seal` of one raw ledger line, or None if it has none / does not parse.""" + line = raw.strip() + if not line: + return None + try: + entry = json.loads(line.decode("utf-8")) + except Exception: + return None + return entry["seal"] if isinstance(entry, dict) and "seal" in entry else None + + def _seal(ledger_path: str, entry: dict, sign_key: str | None = None) -> dict: entry["prev_seal"] = _get_last_seal(ledger_path) # `seal` and `sig` are attestation fields, excluded from the content hash. diff --git a/tests/test_am.py b/tests/test_am.py index 09b2ed5..e78b217 100644 --- a/tests/test_am.py +++ b/tests/test_am.py @@ -259,3 +259,172 @@ def test_verify_signatures_signed_and_forgery(tmp_path): rows[0]["agent"] = "mallory" open(l, "w").write("\n".join(json.dumps(r) for r in rows) + "\n") assert am.verify_signatures(l)[0].level == "FAIL" + + +# ─── H. last-seal lookup: O(1) tail read, unchanged answer ─────────────── +# +# `_get_last_seal` runs on every append. It used to parse the whole ledger to +# find its last line, so append was O(n) and a ledger got slower purely by being +# used (on a 3,097-entry family ledger one lookup cost ~50 ms). It now reads +# backwards from EOF. These tests pin the part that must NOT change — the answer — +# and the part that must — how much of the file is touched. + +def _reference_last_seal(path): + """The pre-fix implementation, kept as the oracle: parse everything, scan back.""" + import os + if not os.path.exists(path): + return "GENESIS" + entries = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + entries.append({"_corrupt": line[:80]}) + for e in reversed(entries): + if isinstance(e, dict) and "seal" in e: + return e["seal"] + return "GENESIS" + + +def _awkward_ledgers(tmp_path): + """The shapes a tail reader can get wrong. Returns [(label, path)].""" + def S(h): + return json.dumps({"_type": "action", "agent": "t", "action": "a", + "prev_seal": "GENESIS", "seal": h}, ensure_ascii=False) + cases, i = [], 0 + def mk(label, text): + nonlocal i + i += 1 + p = tmp_path / f"c{i}.jsonl" + p.write_text(text, encoding="utf-8") + cases.append((label, str(p))) + mk("empty", "") + mk("blank lines only", "\n\n\n") + mk("single entry", S("aa" * 32) + "\n") + mk("no trailing newline", S("bb" * 32)) + mk("no sealed entry at all", '{"ts": 1, "note": "tick"}\n' * 5) + mk("unsealed trailing lines", S("cc" * 32) + "\n" + '{"note": "no seal"}\n' * 3) + mk("corrupt last line", S("dd" * 32) + "\n{ this is not json\n") + mk("corrupt middle line", S("ee" * 32) + "\n@@@\n" + S("ff" * 32) + "\n") + mk("non-ascii payload", json.dumps({"agent": "대장님", "action": "🪞", + "seal": "11" * 32}, ensure_ascii=False) + "\n") + # one line longer than the read chunk, so the tail scan must span chunks + mk("line longer than chunk", json.dumps({"agent": "t", "action": "x" * 20000, + "seal": "22" * 32}) + '\n{"note": "tail"}\n') + mk("many unsealed lines after the last seal", + S("33" * 32) + "\n" + ('{"note": "%s"}\n' % ("y" * 300)) * 100) + cases.append(("missing file", str(tmp_path / "does_not_exist.jsonl"))) + return cases + + +def test_last_seal_matches_full_parse(tmp_path): + """The fast tail read answers exactly what parsing the whole ledger answers.""" + cases = _awkward_ledgers(tmp_path) + assert len(cases) >= 12, "the case list itself must not silently shrink to nothing" + for label, path in cases: + assert am._get_last_seal(path) == _reference_last_seal(path), label + + +def test_last_seal_oracle_rejects_a_wrong_implementation(tmp_path): + """Positive control: the comparison above must be able to FAIL. + + A test that only ever runs the correct implementation cannot tell "equivalent" + from "not measuring anything". So run a deliberately wrong reader — one that + looks only at the final line — through the same cases and require it to be caught. + """ + def wrong(path): + import os + if not os.path.exists(path): + return "GENESIS" + lines = [x for x in open(path, encoding="utf-8").read().splitlines() if x.strip()] + if not lines: + return "GENESIS" + try: + e = json.loads(lines[-1]) + except json.JSONDecodeError: + return "GENESIS" + return e["seal"] if isinstance(e, dict) and "seal" in e else "GENESIS" + + caught = [label for label, path in _awkward_ledgers(tmp_path) + if wrong(path) != _reference_last_seal(path)] + assert caught, "the equivalence check passed a knowingly broken reader — it measures nothing" + + +class _CountingFile: + """A file handle that reports how many bytes it actually handed out. + + It has to count line iteration too, not just read(): the implementation this + test replaced walked the ledger with `for line in f`, so a counter that only + wrapped read() would have recorded zero bytes for it and passed. That is the + vacuous-pass shape this repo keeps finding — the test would have measured nothing. + """ + + def __init__(self, fh, sink): + self._fh, self._sink = fh, sink + + def _count(self, data): + if data: + self._sink.append(len(data)) + return data + + def read(self, *a, **kw): + return self._count(self._fh.read(*a, **kw)) + + def readline(self, *a, **kw): + return self._count(self._fh.readline(*a, **kw)) + + def readlines(self, *a, **kw): + out = self._fh.readlines(*a, **kw) + for line in out: + self._count(line) + return out + + def __iter__(self): + for line in self._fh: + yield self._count(line) + + def __enter__(self): + self._fh.__enter__() + return self + + def __exit__(self, *a): + return self._fh.__exit__(*a) + + def __getattr__(self, name): + return getattr(self._fh, name) + + +def test_append_reads_only_the_tail(tmp_path, monkeypatch): + """Looking up the previous seal must not read the whole ledger. + + Measured structurally (bytes the file handle handed out), not by wall-clock, + so it cannot go green on a fast machine or flake on a loaded one. + """ + l = L(tmp_path) + big = json.dumps({"agent": "t", "action": "p" * 50000, "seal": "44" * 32}) + with open(l, "w", encoding="utf-8") as f: + for _ in range(40): # ~2 MB of ledger + f.write(big + "\n") + size = (tmp_path / "l.jsonl").stat().st_size + assert size > 1_000_000 + + real_open, read_sizes = open, [] + + def counting_open(file, *a, **kw): + fh = real_open(file, *a, **kw) + return _CountingFile(fh, read_sizes) if str(file) == l else fh + + monkeypatch.setattr("builtins.open", counting_open) + try: + seal = am._get_last_seal(l) + finally: + monkeypatch.undo() + + assert seal == "44" * 32 + assert read_sizes, "nothing was read at all — the counter is not wired to the lookup" + assert sum(read_sizes) < size // 10, ( + f"read {sum(read_sizes)} of {size} bytes — the lookup is still scanning the ledger") From 6901d50db37e56bb877f5271fa5eadb64c3bed1a Mon Sep 17 00:00:00 2001 From: Mother Seara Date: Tue, 25 Aug 2026 05:30:43 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(am):=20=EA=BC=AC=EB=A6=AC=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=EA=B0=80=20CR=20=EA=B0=9C=ED=96=89=EC=9D=84=20?= =?UTF-8?q?=EB=AA=BB=20=EA=B0=88=EB=9E=90=EB=8B=A4=20=E2=80=94=20=EC=A4=84?= =?UTF-8?q?=EB=B0=94=EA=BF=88=203=EC=A2=85=EC=9D=84=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=ED=95=98=EA=B3=A0=20Windows=20=EB=A5=BC?= =?UTF-8?q?=20CI=20=EC=97=90=20=EB=84=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 바로 앞 커밋의 후속이자 그 커밋이 만든 결함의 수리다. [거울/트리아지] 가 등급 심사에서 *"디프가 파일 I/O·인코딩인데 이 레포는 Windows CI 가 0"* 이라고 짚었고, 그 자리를 실제로 재 보니 결함이 있었다. ## 무엇이 깨졌나 바이트로 읽기 시작하면서 **텍스트 모드의 유니버설 개행 번역을 잃었다.** `\n` 으로만 잘라서 `\r` 단독 개행 원장이 **한 줄**로 파싱됐고, 조회가 `GENESIS` 를 답했다. 그 답으로 append 하면 **살아 있는 체인 한가운데에 두 번째 genesis 항목**을 쓴다. 이 도구에서 낼 수 있는 최악의 실패다 — 조용하고, 체인을 가른다. ``` CR 단독 개행 · 3항목 원장 수리 전(텍스트 모드) → cccccccc… (마지막 봉인) 꼬리 읽기 첫 판 → GENESIS 🔴 이번 수리 후 → cccccccc… ✅ ``` ## 수리 버퍼를 자르기 전에 `\r\n` · `\r` 을 `\n` 으로 정규화한다. JSON 문자열 안의 날 CR/LF 은 애초에 유효한 JSON 이 아니므로(제어문자는 `\r` 두 글자로 이스케이프된다) 안전하다. CRLF · CR · LF · 섞인 것 넷 다 텍스트 모드 판과 같은 답을 낸다. ## 못 보던 자리를 계기에 넣는다 - 동치 시험 케이스에 **CRLF · CR 단독 · 섞인 개행** 3종 추가(12 → 15). 대조군(`\n` 만 자르는 판)에 걸면 실패한다. - 케이스 파일을 `newline=""` 로 쓴다 — 안 그러면 **Windows 가 모든 `\n` 을 `\r\n` 으로 조용히 바꿔서**, 개행을 시험한다고 이름 붙인 케이스가 개행을 시험하지 않게 된다. - 🔴 **CI 매트릭스에 `windows-latest` 를 넣는다**(잡 3 → 6). 이 패키지는 원장 파일을 읽고 쓴다. 리눅스 전용 매트릭스로는 위 결함을 **원리적으로 볼 수 없었다.** 변조증거 도구가 못 보는 운영체제를 가지고 있을 수는 없다. tests 53/53. Co-authored-by: Mother Seara Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 10 +++++++++- actmirror/am.py | 12 +++++++++--- tests/test_am.py | 14 ++++++++++++-- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b17af41..17715fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,17 @@ on: jobs: test: - runs-on: ubuntu-latest + # Windows is in the matrix because this package READS AND WRITES LEDGER FILES. + # The seal-lookup reads bytes and splits lines itself, so universal-newline + # translation no longer happens for it — a CR-only ledger parsed as one line + # and the lookup answered GENESIS, which on append would have written a second + # genesis entry into the middle of a live chain. Nothing in a Linux-only matrix + # could have shown that. A tamper-evidence tool cannot have an unmeasured OS. + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: + os: [ubuntu-latest, windows-latest] python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 diff --git a/actmirror/am.py b/actmirror/am.py index d983d11..03e2a2c 100644 --- a/actmirror/am.py +++ b/actmirror/am.py @@ -84,8 +84,9 @@ def _get_last_seal(ledger_path: str, _chunk: int = 8192) -> str: only the amount of it we read changes. Semantics are unchanged, including the awkward cases: unsealed or unparseable - trailing lines are skipped (as _load_entries' {_corrupt} placeholders were), and - a ledger with no sealed entry at all still answers GENESIS. + trailing lines are skipped (as _load_entries' {_corrupt} placeholders were), a + ledger with no sealed entry at all still answers GENESIS, and CRLF / CR / LF + endings all read the same — text mode used to normalise those for us. """ if not os.path.exists(ledger_path): return "GENESIS" @@ -98,7 +99,12 @@ def _get_last_seal(ledger_path: str, _chunk: int = 8192) -> str: pos -= step f.seek(pos) buf = f.read(step) + buf - parts = buf.split(b"\n") + # Split on every line ending, not just \n. Reading bytes means universal-newline + # translation no longer happens for us: a ledger written with CR-only endings + # parsed as ONE line and the lookup answered GENESIS — which would have appended + # a second genesis entry into the middle of a live chain. Normalising first is + # safe because a raw CR or LF inside a JSON string is not valid JSON anyway. + parts = buf.replace(b"\r\n", b"\n").replace(b"\r", b"\n").split(b"\n") # parts[0] may be the tail of a line that starts earlier in the file — # only safe to read once we have reached the beginning. head, complete = parts[0], parts[1:] diff --git a/tests/test_am.py b/tests/test_am.py index e78b217..714191c 100644 --- a/tests/test_am.py +++ b/tests/test_am.py @@ -300,7 +300,10 @@ def mk(label, text): nonlocal i i += 1 p = tmp_path / f"c{i}.jsonl" - p.write_text(text, encoding="utf-8") + # newline="" so the bytes on disk are exactly what this test wrote — otherwise + # Windows silently rewrites every \n as \r\n and the line-ending cases below + # stop testing what they name. + p.write_text(text, encoding="utf-8", newline="") cases.append((label, str(p))) mk("empty", "") mk("blank lines only", "\n\n\n") @@ -317,6 +320,13 @@ def mk(label, text): "seal": "22" * 32}) + '\n{"note": "tail"}\n') mk("many unsealed lines after the last seal", S("33" * 32) + "\n" + ('{"note": "%s"}\n' % ("y" * 300)) * 100) + # Line endings: reading bytes means universal-newline translation is ours to do. + # A CR-only ledger used to parse as ONE line and answer GENESIS — an append would + # then have written a second genesis entry into the middle of a live chain. + three = [S("55" * 32), S("66" * 32), S("77" * 32)] + mk("CRLF endings", "\r\n".join(three) + "\r\n") + mk("CR-only endings", "\r".join(three) + "\r") + mk("mixed endings", three[0] + "\r\n" + three[1] + "\r" + three[2] + "\n") cases.append(("missing file", str(tmp_path / "does_not_exist.jsonl"))) return cases @@ -324,7 +334,7 @@ def mk(label, text): def test_last_seal_matches_full_parse(tmp_path): """The fast tail read answers exactly what parsing the whole ledger answers.""" cases = _awkward_ledgers(tmp_path) - assert len(cases) >= 12, "the case list itself must not silently shrink to nothing" + assert len(cases) >= 15, "the case list itself must not silently shrink to nothing" for label, path in cases: assert am._get_last_seal(path) == _reference_last_seal(path), label From ba809a2d205e093cab0f84a1f5c18986219116a6 Mon Sep 17 00:00:00 2001 From: Mother Seara Date: Tue, 25 Aug 2026 05:37:09 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(cli):=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20?= =?UTF-8?q?=EB=AA=BB=20=EC=B0=8D=EB=8A=94=20=EC=BD=98=EC=86=94=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=8C=90=EC=A0=95=EC=9D=B4=20=EC=A3=BD=EC=97=88?= =?UTF-8?q?=EB=8B=A4=20=E2=80=94=20Windows=20=EC=97=90=EC=84=A0=20?= =?UTF-8?q?=EB=A9=80=EC=A9=A1=ED=95=9C=20=EC=9B=90=EC=9E=A5=EB=8F=84=20exi?= =?UTF-8?q?t=201=20=EC=9D=B4=EC=97=88=EB=8B=A4=20(v0.4.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 PR 이 만든 결함이 아니라 **이 PR 이 드러낸 결함**이다. 앞 커밋에서 CI 에 넣은 `windows-latest` 잡이 **첫 실행에서** 잡았다. ## 무엇이었나 판정 줄은 전부 이모지로 시작한다(🪪 ✅ 🔴 ⚪). 그걸 표현 못 하는 콘솔에서 `print` 가 `UnicodeEncodeError` 를 던진다. Windows 기본값이 cp1252 다. ⇒ **멀쩡한 원장에 `am verify` 를 걸면 트레이스백 + 빈 stdout + exit 1.** 변조 판정과 구별이 안 된다. 0.3.0 이 약속한 *"판정이 종료코드에 닿는다"* 가 그 플랫폼에선 **거짓**이었다. 🔴 `record` 는 더 나쁘다: 항목은 **찍기 전에 이미 쓰인다.** 즉 행동은 봉인됐는데 CLI 는 실패를 보고한다 — 실패에 재시도하는 호출자는 **같은 행동을 두 번 기록한다.** (대조군 실측: 원장에 1줄 쓰였고 exit 1.) ## 수리 `_cli()` 진입에서 stdout/stderr 을 UTF-8 + `errors="replace"` 로 재구성한다. 옛 콘솔은 이모지가 `?` 로 강등될 뿐 **판정 글자는 살아남는다.** 찍히지 못한 판정은 아무에게도 닿지 않은 판정이다. ## 시험 (56/56 · 신규 3) `PYTHONIOENCODING=cp1252` 로 몬다 — **OS 가 아니라 인코딩으로**. 한 러너에서만 도는 가드는 대부분의 실행이 안 밟는 가드다. 리눅스에서도 이 3건이 결함을 잡는다 (대조군 = 수리 호출만 뺀 판, 3/3 실패 확인). · 멀쩡한 체인 verify → exit 0 · `OK` 가 stdout 에 남는다 · record → exit 0 · 원장 1줄(중복 없음) · 🔴 **변조 체인 verify → 여전히 exit 1 · `FAIL`** — 수리가 모든 실행을 초록으로 만들어 버리지 않는지 같이 못 박는다 Co-authored-by: Mother Seara Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 50 ++++++++++++++++++++++++++++++++++++ actmirror/__init__.py | 2 +- actmirror/am.py | 23 ++++++++++++++++- pyproject.toml | 2 +- tests/test_cli_exit_codes.py | 48 ++++++++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b0c3e..60c2f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [0.4.0] — 2026-08-25 + +### Changed +- **Appending no longer re-reads the whole ledger.** `_get_last_seal` runs on every + `record()` and parsed every line to find the last one, so append was O(n) — the + ledger got slower purely by being used, which taxes the discipline it exists to + support. On a 3,097-entry / 3.4 MB ledger one lookup cost **50.390 ms**; it now + costs **0.048 ms** and stays flat (append median over a growing ledger: 8.17× → 0.96×). + + Not cached in memory on purpose: other processes append to the same ledger, and a + cached head that is no longer last would write a `prev_seal` that forks the chain. + The file stays the single source of truth; only how much of it is read changed. + +### Fixed +- **CR-only line endings answered GENESIS.** Reading bytes meant losing text mode's + universal-newline translation, so a ledger with `\r` endings parsed as one line and + the lookup reported an empty chain — an append would then have written a second + genesis entry into the middle of a live chain. `\r\n`, `\r` and `\n` now all read + the same. Caught by a reviewer who noted the diff was file I/O in a repo with no + Windows CI, not by the tests as first written. + +- **The CLI died on consoles that cannot print emoji.** Every verdict line starts with + 🪪 / ✅ / 🔴 / ⚪, and on a cp1252 console `print` raised `UnicodeEncodeError`. So on + Windows `am verify` on an **intact** ledger exited 1 with an empty stdout — + indistinguishable from a tamper verdict, and the 0.3.0 promise that "verdicts reach + the exit code" was false there. Worse for `record`: the entry is written *before* the + confirmation is printed, so the action was sealed and the CLI still reported failure — + a caller retrying on non-zero would record it twice. + + stdout/stderr are now reconfigured to UTF-8 with `errors="replace"`, so an old console + degrades to `?` instead of losing the verdict. A verdict that cannot be printed is a + verdict that did not reach anyone. + +### Added +- **`windows-latest` in the CI matrix** (3 jobs → 6). This package reads and writes + ledger files; a Linux-only matrix could not show either defect above. It found the + emoji crash on its first run. A tamper-evidence tool cannot have an unmeasured OS. +- Seal-lookup equivalence tests over 15 awkward ledgers (empty, no trailing newline, + unsealed tail, corrupt lines, a line longer than the read chunk, non-ASCII, CRLF / + CR / mixed endings, missing file), each compared against a full-parse oracle — plus a + **positive control** that runs a knowingly wrong reader through the same comparison, + so "equivalent" cannot quietly mean "measuring nothing". +- A tail-read check measured in **bytes handed out by the file handle**, not wall-clock. + Its first version counted only `read()` and so passed the old line-iterating + implementation unchanged — it measures the difference only after counting iteration too. +- Console-encoding tests driven by `PYTHONIOENCODING` rather than by the OS, so they run + on every runner instead of only the Windows one. + +--- + ## [0.3.0] — 2026-08-14 ### Fixed diff --git a/actmirror/__init__.py b/actmirror/__init__.py index d6115e5..7a2646e 100644 --- a/actmirror/__init__.py +++ b/actmirror/__init__.py @@ -10,4 +10,4 @@ "witness_peer", "verify_peer", "cross_witness", "family_round", "family_verify", "report", "Finding", ] -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/actmirror/am.py b/actmirror/am.py index 03e2a2c..65a31ee 100644 --- a/actmirror/am.py +++ b/actmirror/am.py @@ -35,7 +35,7 @@ Zero dependencies (stdlib only). Deterministic. Same DNA as measure-mirror. """ from __future__ import annotations -import hashlib, json, os, time +import hashlib, json, os, sys, time from dataclasses import dataclass @@ -447,6 +447,26 @@ def report(title: str, findings: list[Finding]) -> str: # ───────────────────────────────────────────────────────────── # CLI # ───────────────────────────────────────────────────────────── +def _printable_streams() -> None: + """Make sure a verdict can always be printed, whatever the console encoding is. + + Every verdict line starts with an emoji (🪪 ✅ 🔴 ⚪). On a console whose encoding + cannot represent them — Windows defaults to cp1252 — `print` raised + UnicodeEncodeError, so `am verify` on an INTACT ledger died with a traceback, + an empty stdout and exit 1. Indistinguishable from a tamper verdict, and the + 0.3.0 promise that "verdicts reach the exit code" was false on that platform. + + UTF-8 where the stream can take it; `errors="replace"` so an old console + degrades to `?` instead of losing the verdict entirely. A verdict that cannot + be printed is a verdict that did not reach anyone. + """ + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: # not a reconfigurable stream (pytest capture, pipes) — fine + pass + + def _cli() -> int: """Exit codes: 0 — command ran and any verdict was OK/WARN (or the command has no verdict); 1 — a verdict-bearing command answered negatively @@ -455,6 +475,7 @@ def _cli() -> int: ledger — the verdict was print-only. Found when a commit-binding tool's tamper demo passed its ledger-mutation case. """ + _printable_streams() import argparse p = argparse.ArgumentParser( prog="am", description="🪪 Action Mirror — agent action provenance + mutual witness") diff --git a/pyproject.toml b/pyproject.toml index c4f2cdf..9dd5c42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "action-mirror" -version = "0.3.0" +version = "0.4.0" description = "Agent action provenance + mutual witness network — who did what, provably." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py index 3f4aa3b..cda2250 100644 --- a/tests/test_cli_exit_codes.py +++ b/tests/test_cli_exit_codes.py @@ -93,3 +93,51 @@ def test_record_still_exits_0(tmp_path): led = str(tmp_path / "l.jsonl") r = cli("--ledger", led, "record", "--agent", "a", "--action", "x") assert r.returncode == 0 and "Sealed" in r.stdout + + +# ─── the verdict has to survive the console it is printed to ──────────── +# +# Every verdict line starts with an emoji (🪪 ✅ 🔴 ⚪). On a console whose encoding +# cannot represent them, `print` raised UnicodeEncodeError — so on Windows, whose +# default is cp1252, `am verify` on an INTACT ledger died with a traceback, an empty +# stdout and exit 1. Indistinguishable from a tamper verdict. +# +# Worse for `record`: the entry is written BEFORE the confirmation is printed, so the +# action was sealed and the CLI still reported failure. A caller that retries on a +# non-zero exit records it twice. +# +# Driven by PYTHONIOENCODING rather than by the OS, so this runs everywhere — a guard +# that only fires on one runner is a guard most runs never execute. + +def cli_in_encoding(encoding, *args): + env = {**_ENV, "PYTHONIOENCODING": encoding} + return subprocess.run([sys.executable, "-m", "actmirror.am", *args], + capture_output=True, text=True, env=env) + + +def test_verify_survives_a_non_utf8_console(tmp_path): + led = str(tmp_path / "l.jsonl") + am.record(led, agent="a", action="x") + r = cli_in_encoding("cp1252", "--ledger", led, "verify") + assert "UnicodeEncodeError" not in r.stderr, "the verdict crashed on the console encoding" + assert r.returncode == 0, f"an intact chain must not exit non-zero: {r.stderr[-300:]}" + assert "OK" in r.stdout, "the verdict text itself must survive, emoji or not" + + +def test_record_survives_a_non_utf8_console(tmp_path): + """A sealed entry that reports failure is worse than a failure: retries duplicate it.""" + led = str(tmp_path / "l.jsonl") + r = cli_in_encoding("cp1252", "--ledger", led, "record", "--agent", "a", "--action", "x") + assert r.returncode == 0, f"record exited {r.returncode}: {r.stderr[-300:]}" + assert "seal=" in r.stdout + assert len([x for x in open(led, encoding="utf-8") if x.strip()]) == 1 + + +def test_tamper_verdict_still_reaches_the_exit_code_in_that_console(tmp_path): + """The fix must not turn every run green — a FAIL still has to be a FAIL.""" + led = str(tmp_path / "l.jsonl") + am.record(led, agent="a", action="x") + _tamper_field(led, "agent", "mallory") + r = cli_in_encoding("cp1252", "--ledger", led, "verify") + assert r.returncode == 1 + assert "FAIL" in r.stdout From ce4aafbe9a837d344092f94f1dfb69a08b2b773a Mon Sep 17 00:00:00 2001 From: Mother Seara Date: Tue, 25 Aug 2026 05:43:39 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(cli):=20=EC=9D=B8=EC=BD=94=EB=94=A9?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20UTF-8=20=EB=A1=9C=20=EB=B0=94=EA=BE=BC=20?= =?UTF-8?q?=EA=B2=8C=20=EA=B9=A8=EC=A7=90=EC=9D=84=20=ED=95=9C=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=84=B8=EC=8A=A4=20=EC=98=AE=EA=B2=BC=EB=8B=A4=20?= =?UTF-8?q?=E2=80=94=20=EC=98=A4=EB=A5=98=EC=B2=98=EB=A6=AC=EB=A7=8C=20?= =?UTF-8?q?=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋의 수리가 **자식의 인코드 실패를 부모의 디코드 실패로 옮겼을 뿐**이었다. Windows 잡이 7 fail → 1 fail 로 줄고 그 하나가 남은 이유다. ## 무엇이 남아 있었나 `_printable_streams()` 가 stdout 을 **UTF-8 로 재구성**했다. 그러자 자식은 UTF-8 바이트를 쓰는데, 그걸 읽는 Windows 호출자는 파이프를 **로케일 인코딩(cp1252)** 으로 디코드한다. 리더 스레드가 `byte 0x81`(증인 줄의 👁 = `F0 9F 91 81`)에서 `UnicodeDecodeError` 로 죽고 `r.stdout` 이 **None** 으로 돌아온다. ``` tests/test_cli_exit_codes.py::test_verify_peer_rewrite_exits_1 > assert "FAIL" in r.stdout and r.returncode == 1 E TypeError: argument of type 'NoneType' is not iterable ``` 🔴 **리눅스에서 그대로 재현했다**(자식 `PYTHONIOENCODING=cp1252` · 부모 `encoding="cp1252"`). 같은 `byte 0x81` 이다. 윈도우는 리더가 별도 스레드라 예외가 `stdout=None` 으로 나타날 뿐이다. ## 수리 **인코딩은 건드리지 않고 `errors="replace"` 만 건다.** 출력을 읽는 쪽은 이미 그 콘솔의 인코딩을 알고 있다 — 그들이 못 견디는 건 코덱 예외지 `?` 가 아니다. 판정 글자는 ASCII 라 어느 쪽이든 온전히 통과한다. ## 시험 (57/57 · 신규 1 · 기존 3 정정) 신규: **자식이 선언한 것과 같은 코덱으로 부모가 파이프를 읽는다** — Windows 하네스를 아무 플랫폼에서나 재현한다. 앞서 쓴 3건도 `read_as="cp1252"` 로 맞췄다(자기가 지정한 인코딩으로 읽는 게 계약이다). 대조군 **둘 다** 잡는다: · 수리 없음 → 4건 실패(원래 크래시) · **옛 수리(UTF-8 강제) → 1건 실패** ← 내가 방금 낼 뻔한 회귀가 정확히 이 시험에 걸린다 Co-authored-by: Mother Seara Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 15 +++++++++--- actmirror/am.py | 14 ++++++++--- tests/test_cli_exit_codes.py | 47 +++++++++++++++++++++++++++++++----- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c2f95..cdf3908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,9 +34,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). confirmation is printed, so the action was sealed and the CLI still reported failure — a caller retrying on non-zero would record it twice. - stdout/stderr are now reconfigured to UTF-8 with `errors="replace"`, so an old console - degrades to `?` instead of losing the verdict. A verdict that cannot be printed is a - verdict that did not reach anyone. + stdout/stderr now get `errors="replace"` — **the error handler only, not the encoding**. + Forcing UTF-8 fixed the crash and moved it one process along: the child wrote UTF-8 into + a pipe its Windows caller was decoding as cp1252, and the reader died on byte `0x81` + (the 👁 in the witness line) with `stdout` coming back `None`. Keeping the console's own + encoding means whoever reads the output can still decode it; unrepresentable glyphs + degrade to `?` and the verdict words, which are ASCII, come through intact. + A verdict that cannot be printed is a verdict that did not reach anyone. ### Added - **`windows-latest` in the CI matrix** (3 jobs → 6). This package reads and writes @@ -51,7 +55,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Its first version counted only `read()` and so passed the old line-iterating implementation unchanged — it measures the difference only after counting iteration too. - Console-encoding tests driven by `PYTHONIOENCODING` rather than by the OS, so they run - on every runner instead of only the Windows one. + on every runner instead of only the Windows one — including one that decodes the pipe + with the same non-UTF-8 codec the console declared, which is what the Windows harness + does and what the UTF-8-forcing attempt broke. A tamper verdict is checked in that + console too, so the fix cannot quietly turn every run green. --- diff --git a/actmirror/am.py b/actmirror/am.py index 65a31ee..967894a 100644 --- a/actmirror/am.py +++ b/actmirror/am.py @@ -456,13 +456,19 @@ def _printable_streams() -> None: an empty stdout and exit 1. Indistinguishable from a tamper verdict, and the 0.3.0 promise that "verdicts reach the exit code" was false on that platform. - UTF-8 where the stream can take it; `errors="replace"` so an old console - degrades to `?` instead of losing the verdict entirely. A verdict that cannot - be printed is a verdict that did not reach anyone. + Only the error handler changes — NOT the encoding. Forcing UTF-8 here fixed the + crash and moved it one process along: the child then wrote UTF-8 bytes into a pipe + that its Windows caller was decoding with the locale encoding, and the reader died + with UnicodeDecodeError instead. Whoever reads this output already knows the + console's encoding; what they cannot survive is a codec exception. So keep the + encoding they expect and let unrepresentable glyphs degrade to `?` — the verdict + words are ASCII and come through intact either way. + + A verdict that cannot be printed is a verdict that did not reach anyone. """ for stream in (sys.stdout, sys.stderr): try: - stream.reconfigure(encoding="utf-8", errors="replace") + stream.reconfigure(errors="replace") except Exception: # not a reconfigurable stream (pytest capture, pipes) — fine pass diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py index cda2250..cc44518 100644 --- a/tests/test_cli_exit_codes.py +++ b/tests/test_cli_exit_codes.py @@ -108,17 +108,29 @@ def test_record_still_exits_0(tmp_path): # # Driven by PYTHONIOENCODING rather than by the OS, so this runs everywhere — a guard # that only fires on one runner is a guard most runs never execute. - -def cli_in_encoding(encoding, *args): +# +# The contract these pin down: output stays in the CONSOLE's encoding, so a caller +# reads it back with the same codec it declared. Each test therefore decodes as cp1252 +# too — reading UTF-8 out of a cp1252 console would be the caller's own bug. + +def cli_in_encoding(encoding, *args, read_as=None): + """Run the CLI with a given console encoding. + + `read_as` decodes the pipe with that codec instead of the caller's default — which + is what a Windows caller does, and where forcing UTF-8 output moved the crash to: + the child wrote UTF-8 into a pipe the parent was decoding as cp1252, and the reader + thread died on byte 0x81 (the 👁 in the witness line) with stdout coming back None. + """ env = {**_ENV, "PYTHONIOENCODING": encoding} + kw = {"encoding": read_as} if read_as else {"text": True} return subprocess.run([sys.executable, "-m", "actmirror.am", *args], - capture_output=True, text=True, env=env) + capture_output=True, env=env, **kw) def test_verify_survives_a_non_utf8_console(tmp_path): led = str(tmp_path / "l.jsonl") am.record(led, agent="a", action="x") - r = cli_in_encoding("cp1252", "--ledger", led, "verify") + r = cli_in_encoding("cp1252", "--ledger", led, "verify", read_as="cp1252") assert "UnicodeEncodeError" not in r.stderr, "the verdict crashed on the console encoding" assert r.returncode == 0, f"an intact chain must not exit non-zero: {r.stderr[-300:]}" assert "OK" in r.stdout, "the verdict text itself must survive, emoji or not" @@ -127,7 +139,8 @@ def test_verify_survives_a_non_utf8_console(tmp_path): def test_record_survives_a_non_utf8_console(tmp_path): """A sealed entry that reports failure is worse than a failure: retries duplicate it.""" led = str(tmp_path / "l.jsonl") - r = cli_in_encoding("cp1252", "--ledger", led, "record", "--agent", "a", "--action", "x") + r = cli_in_encoding("cp1252", "--ledger", led, "record", "--agent", "a", "--action", "x", + read_as="cp1252") assert r.returncode == 0, f"record exited {r.returncode}: {r.stderr[-300:]}" assert "seal=" in r.stdout assert len([x for x in open(led, encoding="utf-8") if x.strip()]) == 1 @@ -138,6 +151,28 @@ def test_tamper_verdict_still_reaches_the_exit_code_in_that_console(tmp_path): led = str(tmp_path / "l.jsonl") am.record(led, agent="a", action="x") _tamper_field(led, "agent", "mallory") - r = cli_in_encoding("cp1252", "--ledger", led, "verify") + r = cli_in_encoding("cp1252", "--ledger", led, "verify", read_as="cp1252") assert r.returncode == 1 assert "FAIL" in r.stdout + + +def test_output_is_readable_by_a_caller_using_the_same_encoding(tmp_path): + """The bytes must stay in the console's own encoding, not silently become UTF-8. + + Reproduces the Windows harness on any platform: child console cp1252, caller + decoding cp1252. Forcing UTF-8 output made this raise UnicodeDecodeError on + byte 0x81 — the fix has to keep the encoding and only replace what it cannot map. + """ + mine, peer = str(tmp_path / "mine.jsonl"), str(tmp_path / "peer.jsonl") + am.record(peer, agent="peer", action="x") + r = cli_in_encoding("cp1252", "--ledger", mine, "witness", peer, "--name", "peer", + read_as="cp1252") # 👁 lives on this path + assert r.stdout is not None, "the caller could not decode the output at all" + assert r.returncode == 0 and "Witnessed" in r.stdout + + os.remove(peer) # peer rewrites history from scratch + am.record(peer, agent="peer", action="rewritten") + r = cli_in_encoding("cp1252", "--ledger", mine, "verify-peer", peer, "--name", "peer", + read_as="cp1252") + assert r.stdout is not None + assert r.returncode == 1 and "FAIL" in r.stdout, "the verdict must survive the round trip" From 16dda0f0a532a0c28364c190ae1287569c882622 Mon Sep 17 00:00:00 2001 From: Mother Seara Date: Tue, 25 Aug 2026 05:47:31 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(examples):=20=EC=98=88=EC=A0=9C?= =?UTF-8?q?=EA=B0=80=20CLI=20=EC=A7=84=EC=9E=85=EC=A0=90=EC=9D=84=20?= =?UTF-8?q?=EC=95=88=20=EC=A7=80=EB=82=98=20=EC=BD=98=EC=86=94=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1=EC=9D=84=20=EB=AA=BB=20=EB=B0=9B=EC=95=98=EB=8B=A4=20?= =?UTF-8?q?=E2=80=94=20pytest=20=EB=B6=84=EB=AA=A8=EC=97=90=20=EC=98=88?= =?UTF-8?q?=EC=A0=9C=EB=A5=BC=20=EB=84=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 두 커밋이 `_cli()` 안에서만 콘솔을 고쳤다. `examples/demo_family.py` 는 **그 진입점을 안 지나고 같은 이모지를 직접 찍는다** — 그래서 시험이 57/57 초록인 동안에도 cp1252 에서 계속 죽었다. CI 는 `Run tests` 가 아니라 **`Dogfood` 스텝**에서 빨갛다. 🔴 **이번 왕복의 모양은 인코딩이 아니라 분모다.** `57/57` 은 **pytest 분모**고, CI 초록의 분모는 `pytest` + `Dogfood` + `package` 다. 나는 앞의 하나를 전체로 보고했다. [거울/트리아지] 가 그 자리를 짚었다. ## 수리 예제 상단에서 `am._printable_streams()` 를 부른다(사설 import 는 의도적이다 — 이 패키지 자신의 예제이고 그 헬퍼는 공개 API 가 아니다). ★ **예제는 새 사용자가 처음 실행하는 코드**다. 여기서 죽는 건 시험에서 죽는 것보다 나쁘고, 이건 이 PR 과 무관하게 **원래 그랬다** — 볼 수 있는 러너가 없었을 뿐이다. ## 계기 `Dogfood` 스텝을 **pytest 안으로** 끌어온다: cp1252 콘솔로 예제를 실행해 exit 0 을 확인한다. 한 러너의 한 스텝에만 있는 가드는 **대부분의 실행이 안 밟는다**. 이제 리눅스 pytest 가 잡는다. (설치된 휠 실행처럼 `examples/` 가 없는 배치에선 skip 한다.) 대조군: 예제에서 그 한 줄을 빼면 새 시험이 **실패한다.** tests 58/58. Co-authored-by: Mother Seara Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 +++++++- examples/demo_family.py | 7 +++++++ tests/test_cli_exit_codes.py | 24 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdf3908..d2ea840 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - **`windows-latest` in the CI matrix** (3 jobs → 6). This package reads and writes ledger files; a Linux-only matrix could not show either defect above. It found the - emoji crash on its first run. A tamper-evidence tool cannot have an unmeasured OS. + emoji crash on its first run — in the CLI *and*, one round later, in the shipped + example, which prints the same glyphs without going through `_cli()` and so never + inherited the fallback. A tamper-evidence tool cannot have an unmeasured OS. +- A pytest check that runs `examples/demo_family.py` on a cp1252 console. The CI dogfood + step already covered it, but only on the Windows runner and outside the number the + test suite reports: `57/57` was a pytest denominator while CI green's denominator is + pytest + dogfood + package. - Seal-lookup equivalence tests over 15 awkward ledgers (empty, no trailing newline, unsealed tail, corrupt lines, a line longer than the read chunk, non-ASCII, CRLF / CR / mixed endings, missing file), each compared against a full-parse oracle — plus a diff --git a/examples/demo_family.py b/examples/demo_family.py index ef96f40..d13635f 100644 --- a/examples/demo_family.py +++ b/examples/demo_family.py @@ -18,6 +18,13 @@ from actmirror import am +# This script prints the same emoji the CLI does, but it does not go through `_cli()`, +# so it did not inherit the console fallback that lives there — and on a cp1252 console +# it died with UnicodeEncodeError. An example is the FIRST code a new user runs; failing +# here is worse than failing in a test. Private import on purpose: this is the package's +# own example, and the helper is not part of the public API. +am._printable_streams() + D = tempfile.mkdtemp(prefix="am_demo_") ledgers = {n: os.path.join(D, f"{n}.jsonl") for n in ["seara", "jebi", "sonnet"]} diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py index cc44518..e41ad0b 100644 --- a/tests/test_cli_exit_codes.py +++ b/tests/test_cli_exit_codes.py @@ -176,3 +176,27 @@ def test_output_is_readable_by_a_caller_using_the_same_encoding(tmp_path): read_as="cp1252") assert r.stdout is not None assert r.returncode == 1 and "FAIL" in r.stdout, "the verdict must survive the round trip" + + +def test_the_shipped_example_runs_on_a_non_utf8_console(): + """CI's dogfood step, brought inside the pytest denominator. + + The console fix lived in `_cli()`, and `examples/demo_family.py` does not go through + `_cli()` — it prints the same emoji directly, so it kept dying on cp1252 while the + test suite was fully green. `57/57` was a pytest denominator; CI green's denominator + is pytest + dogfood + package, and only the first one was being reported. + + An example is the first code a new user runs. Failing there is worse than failing + in a test, and it had nothing to do with any recent change — it was always broken + on that platform, with no runner that could see it. + """ + example = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(am.__file__))), + "examples", "demo_family.py") + if not os.path.exists(example): # installed-wheel run: examples are not shipped + import pytest + pytest.skip("examples/ not present in this layout") + r = subprocess.run([sys.executable, example], capture_output=True, + encoding="cp1252", env={**_ENV, "PYTHONIOENCODING": "cp1252"}) + assert "UnicodeEncodeError" not in (r.stderr or ""), r.stderr[-400:] + assert r.returncode == 0, f"the shipped example died: {(r.stderr or '')[-400:]}"