diff --git a/scripts/epic_body_staleness.py b/scripts/epic_body_staleness.py index 9ad511f127..92c77e772d 100644 --- a/scripts/epic_body_staleness.py +++ b/scripts/epic_body_staleness.py @@ -433,6 +433,38 @@ def list_merged_prs(repo: str, limit: int) -> list[MergedPullRequest]: )[:limit] +def _emit_utf8(text: str, indent: int | None = None) -> None: + """Ecrit `text` sur stdout en UTF-8, quel que soit le codepage de la console. + + Les titres d'EPIC du depot portent des caracteres hors cp1252 (U+21C4 par + exemple). Sur une console Windows cp1252, un `json.dump(..., sys.stdout)` + leve alors UnicodeEncodeError et le rapport entier est perdu, y compris + pour les EPIC dont le titre est encodable. + """ + stream = sys.stdout + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + try: + reconfigure(encoding="utf-8") + except (ValueError, OSError): + # Flux deja detache ou non reconfigurable : on tente l'ecriture, + # le repli ASCII ci-dessous prendra le relais si besoin. + pass + try: + stream.write(text) + except UnicodeEncodeError: + # Dernier recours : ne jamais perdre le rapport pour un caractere. + # On re-serialise en JSON ASCII plutot qu'echapper le texte deja rendu : + # backslashreplace produisait \U0001f9ed pour un caractere astral + # (emoji de titre, mesure : U+1F9ED dans le corpus du 2026-11-05), et + # \Uxxxxxxxx n'est PAS un echappement JSON valide (\uXXXX, 4 chiffres) + # -- le repli cassait lui-meme le contrat qu'il gardait. + stream.write( + json.dumps(json.loads(text), ensure_ascii=True, indent=indent) + ) + stream.flush() + + def _cli(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--repo", default=None) @@ -459,13 +491,14 @@ def _cli(argv: list[str] | None = None) -> int: print(f"epic_body_staleness: {exc}", file=sys.stderr) return 1 - json.dump( - build_payload(epics, merged_prs), - sys.stdout, + payload = build_payload(epics, merged_prs) + indent = 2 if args.pretty else None + rendered = json.dumps( + payload, ensure_ascii=False, - indent=2 if args.pretty else None, + indent=indent, ) - print() + _emit_utf8(rendered + "\n", indent) return 0 diff --git a/scripts/tests/test_epic_body_staleness.py b/scripts/tests/test_epic_body_staleness.py index f9e638bc3f..19aea26041 100644 --- a/scripts/tests/test_epic_body_staleness.py +++ b/scripts/tests/test_epic_body_staleness.py @@ -4,6 +4,7 @@ from __future__ import annotations import importlib.util +import io import json import sys from pathlib import Path @@ -419,6 +420,67 @@ def test_cli_prints_json_and_never_writes(monkeypatch, capsys): assert payload["corpus"]["open_epics_examined"] == 1 +def test_cli_writes_utf8_even_on_a_cp1252_stdout(monkeypatch): + """Regression: un titre d'EPIC hors cp1252 faisait perdre le rapport entier. + + Sur une console Windows cp1252, `json.dump(..., sys.stdout)` levait + UnicodeEncodeError sur U+21C4 et n'ecrivait rien du tout -- pas meme les + EPIC dont le titre etait encodable. + """ + epic = Epic(42, "[EPIC] chaine ⇄ collision", "") + pr = _pr(101, "See #42") + monkeypatch.setattr(_MODULE, "list_open_epics", lambda repo: [epic]) + monkeypatch.setattr(_MODULE, "list_merged_prs", lambda repo, limit: [pr]) + + raw = io.BytesIO() + console = io.TextIOWrapper(raw, encoding="cp1252", newline="") + monkeypatch.setattr(_MODULE.sys, "stdout", console) + + assert _MODULE._cli(["--repo", "example/repo"]) == 0 + payload = json.loads(raw.getvalue().decode("utf-8")) + assert payload["finding_count"] == 1 + assert "⇄" in payload["findings"][0]["title"] + + +class _StreamSansReconfigure: + """Flux cp1252 sans ``reconfigure`` : force le repli ASCII de ``_emit_utf8``.""" + + def __init__(self) -> None: + self._raw = io.BytesIO() + + @property + def encoding(self) -> str: + return "cp1252" + + def write(self, text: str) -> int: + data = text.encode("cp1252") # strict : leve comme une vraie console + self._raw.write(data) + return len(data) + + def flush(self) -> None: + pass + + def ascii_text(self) -> str: + return self._raw.getvalue().decode("ascii") + + +def test_ascii_fallback_stays_valid_json_for_an_astral_title(monkeypatch): + """Regression (repli de #24622) : un emoji astral cassait l'echappement. + + Le repli encodait le texte rendu en ``backslashreplace``, qui produit + ``\\U0001f9ed`` pour un caractere astral -- pas un echappement JSON + valide (``\\uXXXX``, 4 chiffres). Le repli devait alors un document + indechiffrable, exactement ce qu'il existait pour eviter. + """ + stream = _StreamSansReconfigure() + monkeypatch.setattr(_MODULE.sys, "stdout", stream) + + _MODULE._emit_utf8(json.dumps({"title": "constellation 🧭 07"}, ensure_ascii=False)) + + payload = json.loads(stream.ascii_text()) + assert payload["title"] == "constellation 🧭 07" + + def test_cli_reports_read_failure(monkeypatch, capsys): def fail(_repo): raise RuntimeError("GitHub unavailable")