diff --git a/scripts/check_unaddressed_nits.py b/scripts/check_unaddressed_nits.py index 748acbb7ae..7bc34fc561 100644 --- a/scripts/check_unaddressed_nits.py +++ b/scripts/check_unaddressed_nits.py @@ -3272,16 +3272,26 @@ def collect_followup_lifts(pr_data: dict, cutoff: datetime, def _cited_shas(body: str) -> set[str]: - """SHAs cites dans un corps : 7-40 hex, avec AU MOINS une lettre. + """SHAs cites dans un corps : 7-40 hex, avec AU MOINS une lettre ET AU + MOINS un chiffre. Un token 100% numerique de 7+ chiffres (une date 20260830, un run-id) est hex-compatible mais n'est quasi jamais un SHA -- l'exiger lettree evite de partir resoudre une date cote serveur pour rien. + + #16103 defaut 1 : un token 100% LETTRES de a-f est la meme classe de + bruit en francais -- « effacee », « effacees », « deface » satisfont + le motif hexa et se font lire comme des empreintes (levee d'ai-01 du + 2026-09-14T02:45:56Z sur #16022 rendue « cite effacee ... absent des + commits »). Une empreinte Git de 7+ caracteres sans AUCUN chiffre est + improbable ((6/16)^7 ~ 1e-3 au format court) ; + l'exiger chiffre supprime la classe entiere. """ out: set[str] = set() for m in _SHA_CITED.finditer((body or "").lower()): tok = m.group(0) - if any(ch in "abcdef" for ch in tok): + if (any(ch in "abcdef" for ch in tok) + and any(ch.isdigit() for ch in tok)): out.add(tok) return out @@ -4717,7 +4727,23 @@ def analyse_pr(pr: int) -> dict: dismissed_improperly=improper_dismissals(pr)) +def _ensure_utf8_stdout() -> None: + """#16103 defaut 3 : le verdict ne doit jamais dependre de la page de + code de la console. Sous cp1252 (Windows), l'impression d'un commentaire + a relire portant un caractere hors page (`→` levait UnicodeEncodeError + dans _print_unevaluated) crashait APRES le verdict -- rc=1 faux rouge + pour tout consommateur scripte alors que l'analyse disait OK. + Idempotent ; silencieux sous un stdout non reconfigurable (buffers + de test). + """ + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError, OSError): + pass + + def gate(pr: int, as_json: bool) -> int: + _ensure_utf8_stdout() data = gh_json(["pr", "view", str(pr), "--repo", REPO, "--json", FIELDS]) # #13639 + #15556 : resolution serveur du contexte SHA (messages, # arbres rembobines, arbre de tete), AVANT analyse (qui reste pure). @@ -4752,6 +4778,7 @@ def gate(pr: int, as_json: bool) -> int: def audit(limit: int, search: str | None = None) -> int: + _ensure_utf8_stdout() cmd = ["pr", "list", "--repo", REPO, "--state", "merged", "--limit", str(limit), "--json", LIST_FIELDS] if search: diff --git a/scripts/tests/test_check_unaddressed_nits_16103.py b/scripts/tests/test_check_unaddressed_nits_16103.py new file mode 100644 index 0000000000..bc2a750234 --- /dev/null +++ b/scripts/tests/test_check_unaddressed_nits_16103.py @@ -0,0 +1,125 @@ +"""Tests pour les defauts de l'organe B.0 (#16103) livres par #16107. + +Defaut 1 -- un mot francais compose uniquement de lettres hexadecimales +(« effacee ») etait lu comme une empreinte citee : la levee d'ai-01 du +2026-09-14T02:45:56Z sur #16022 etait rendue « cite effacee ... absent +des commits ». Remede : un token cite doit porter AU MOINS un chiffre en +plus de l'exigence existante d'au moins une lettre. Sans le chiffre, la +classe entiere des mots francais en [a-f] (effacee, decalee, efface...) +reste des empreintes fantomes. + +Defaut 3 -- crash cp1252 dans _print_unevaluated : le verdict OK etait +imprime puis l'organe crashait (rc=1 faux rouge) sur un commentaire a +relire contenant `→`. Remede : _ensure_utf8_stdout() aux entrees qui +impriment (gate, audit), silencieux quand stdout n'est pas +reconfigurable. + +Defaut 2 (rebase-amend : un arbre different ne doit plus voider une levee +dont les fichiers de la PR sont intacts) a ete livre independamment sur +`main` par #16037 (`fix(nits,#15973)` -- `_rebase_preserved_by_path` + +carte chemin->blob de la tete en un appel `git/trees?recursive=1`). Ce +fichier ne le redeclare donc PAS : la version mergee fait foi et ses +tests vivent dans `test_check_unaddressed_nits.py` (raisons +`rebase_preserved`, `same_tree`, chemins removed/renamed, cartes +absentes). Seuls 1 et 3 sont couverts ici. +""" +import importlib.util +import io +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +CHECK_PATH = HERE.parent / "check_unaddressed_nits.py" + +spec = importlib.util.spec_from_file_location("check_unaddressed_nits", CHECK_PATH) +mod = importlib.util.module_from_spec(spec) +sys.modules["check_unaddressed_nits"] = mod +spec.loader.exec_module(mod) + +CITED = "230b1194" # 8 hex, lettres ET chiffre -> reste un SHA cite apres fix + + +# --------------------------------------------------------------------------- +# Defaut 1 -- extraction d'empreintes +# --------------------------------------------------------------------------- + +def test_defaut1_mot_francais_hexa_nest_pas_un_sha(): + """Repro #16103 : « effacee » (e-f-f-a-c-e-e, 7 lettres toutes dans + [0-9a-f]) satisfaisait le motif hexa et etait extrait comme SHA.""" + cited = mod._cited_shas( + "Levee -- verifie, le contenu effacee par le push est propre.") + assert "effacee" not in cited + assert cited == set() + + +def test_defaut1_vrai_sha_avec_chiffre_conserve(): + body = f"Levee de la reserve -- verifie firsthand sur {CITED}." + assert mod._cited_shas(body) == {CITED} + + +def test_defaut1_token_lettres_seules_rejete_avec_controles(): + """Toute la classe : lettres-seules rejetees, numeriques-seules + rejetees (regle existante), melange lettres+chiffre accepte.""" + assert mod._cited_shas("deadbeef") == set() # lettres seules + assert mod._cited_shas("20260830") == set() # chiffres seuls (regle anterieure) + assert mod._cited_shas("effacee2") == {"effacee2"} # melange : reste candidat + + +# --------------------------------------------------------------------------- +# Defaut 3 -- crash cp1252 de l'impression +# --------------------------------------------------------------------------- + +UNEVAL_RESULT = { + "unevaluated": [{ + "author": "reviewer", + "at": "2026-09-14T03:00:00Z", + "body": "commentaire a relire portant → et du texte", + "after_last_commit": False, + }], + "unevaluated_total": 1, +} + + +def test_defaut3_repro_crash_cp1252_avant_remade(): + """Repro : sous stdout cp1252, l'impression du commentaire non evalue + leve UnicodeEncodeError (le verdict OK etait deja imprime).""" + old = sys.stdout + sys.stdout = io.TextIOWrapper(io.BytesIO(), encoding="cp1252") + try: + with pytest.raises(UnicodeEncodeError): + mod._print_unevaluated(UNEVAL_RESULT) + finally: + sys.stdout = old + + +def test_defaut3_ensure_utf8_stdout_guerit_limpression(): + """Remede : apres _ensure_utf8_stdout(), la meme impression passe et + encode le caractere en utf-8.""" + old = sys.stdout + buf = io.BytesIO() + sys.stdout = io.TextIOWrapper(buf, encoding="cp1252") + try: + mod._ensure_utf8_stdout() + mod._print_unevaluated(UNEVAL_RESULT) # ne doit pas lever + sys.stdout.flush() + assert "→".encode("utf-8") in buf.getvalue() + finally: + sys.stdout = old + + +def test_defaut3_ensure_utf8_stdout_silencieux_si_non_reconfigurable(): + """Un stdout sans reconfigure (capture exotique, StringIO de test) ne + fait pas echouer l'organe : le remede est silencieux, jamais bloquant.""" + + class NoReconfigure(io.StringIO): + def reconfigure(self, *a, **k): + raise AttributeError("no reconfigure") + + old = sys.stdout + sys.stdout = NoReconfigure() + try: + mod._ensure_utf8_stdout() # ne doit pas lever + finally: + sys.stdout = old