From 9d4bac83cfccfe0ed0eae6f60dbf1f2280a26025 Mon Sep 17 00:00:00 2001 From: jsboige Date: Mon, 31 Aug 2026 02:30:01 +0200 Subject: [PATCH] fix(gate,#13779): le gate B.0 ignorait HOLD, le verbe du coordinateur -- 2 PRs tenues passaient en vert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `variation-protocol.md` §3 donne au coordinateur un seul verbe pour retenir une PR -- HOLD (« HOLD sans remplacement = echec coordinateur »). `_block_emitted` ne connaissait que BLOCAGE/BLOCK : les deux HOLD reels poses le 2026-08-30 sur #13712 et #13724 etaient classes None, et l'organe rendait rc=0 sur des PRs que le coordinateur avait explicitement tenues. Le corps de #13724 dit le defaut lui-meme : « la review NanoClaw est POSTERIEURE au dernier commit et non levee ». - `[HOLD] lane ` rejoint `[BLOCAGE]/[BLOCK] lane` dans BLOCAGE_LANE -- meme pose absolue en tete de ligne - HOLD_HEAD : la forme verdict en tete de CORPS, pose plus stricte que celle de BLOCAGE (debut de corps ET majuscule) parce que « hold » est un mot ordinaire la ou « blocage » est deja presque toujours un verdict - `_lift_participle_after` extrait de la branche (b) et partage par les deux : meme code, appele deux fois -- « HOLD leve / lifted » ne pose rien Mesure ancien/nouveau module cote a cote sur les 32 PRs ouvertes : 2 verdicts changent (blocked False -> True), 0 passe rouge -> vert, et 2 commentaires exactement deviennent BLOCK sur tout le corpus -- les deux HOLD reels. 16 tests, ecrits par leurs faux negatifs : 5 echouent sur le code d'avant, 11 controles bornent l'elargissement (minuscule, milieu de phrase, participe de levee, [OVERRIDE] en tete, blockquote, backticks, frontiere de mot). See #13779 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check_unaddressed_nits.py | 52 +++++-- .../tests/test_check_unaddressed_nits_hold.py | 135 ++++++++++++++++++ 2 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 scripts/tests/test_check_unaddressed_nits_hold.py diff --git a/scripts/check_unaddressed_nits.py b/scripts/check_unaddressed_nits.py index bdf9463fa3..bf49c75bd7 100644 --- a/scripts/check_unaddressed_nits.py +++ b/scripts/check_unaddressed_nits.py @@ -161,8 +161,22 @@ # La forme verdict-gras (`**BLOCAGE ...**` en tete de corps) est couverte par # _block_emitted (2e branche), pas par ce pattern. La prose descriptive peut # suivre sur la meme ligne. Alias anglais `[BLOCK] lane` pour les reviews -# ecrites en anglais. -BLOCAGE_LANE = re.compile(r"(?m)^\s*\[(?:BLOCAGE|BLOCK)\]\s+lane\s+\S+") +# ecrites en anglais. `[HOLD] lane` (#13779) : HOLD est le verbe que +# variation-protocol.md donne au coordinateur (« HOLD sans remplacement = +# echec coordinateur ») — entre crochets et en tete de ligne, il est aussi +# peu ambigu que les deux autres. +BLOCAGE_LANE = re.compile(r"(?m)^\s*\[(?:BLOCAGE|BLOCK|HOLD)\]\s+lane\s+\S+") + +# #13779 — la forme VERDICT du HOLD coordinateur : le corps COMMENCE par HOLD, +# modulo l'emphase markdown (`**HOLD ...**`) et le titre (`## HOLD ...`). Deux +# resserrements par rapport a la pose de BLOCAGE (n'importe ou dans les 60 +# premiers chars, casse indifferente), parce que « hold » est un mot ordinaire +# la ou « blocage » est deja presque toujours un verdict : +# - POSITION : tete de corps, pas tete de fenetre — « je ne mets pas de hold +# sur cette PR » ne pose rien ; +# - CASSE : HOLD majuscule, la forme dans laquelle le protocole et les posts +# du coordinateur l'ecrivent — « hold on, je regarde » ne pose rien. +HOLD_HEAD = re.compile(r"^[\s*_#]*HOLD\b") # Marqueurs de reserve d'un reviewer bot (le verdict est dans le body, pas l'etat). # #12311 — `REQUEST_CHANGES` (verbe, e.g. « [Hermes] Review — REQUEST_CHANGES ») @@ -1105,6 +1119,21 @@ def has_live_marker(body: str, markers: tuple[str, ...]) -> bool: return False +def _lift_participle_after(head: str, end: int) -> bool: + """Le mot qui SUIT l'occurrence est-il un participe de levee ? + + Miroir post-fenetre de `_is_cited` : « BLOCAGE leve », « HOLD lifted » + NOMMENT le blocage pour le clore, ils ne l'emettent pas (mention #11636). + """ + tail = head[end:end + 10].lstrip(" \t:;,.)('\"-—*") + word = "" + for ch in tail: + if not ch.isalpha(): + break + word += ch + return word.lower() in ("leve", "levee", "lifted") + + def _block_emitted(body: str) -> bool: """Le coordinateur POSE-t-il un blocage (verdict, jamais une citation) ? @@ -1121,7 +1150,13 @@ def _block_emitted(body: str) -> bool: la position du verdict, pas une substring de milieu. La narration « pas un blocage » (fixture #11190) vit en section ; l'emission aussi. Les negations immediates (« pas de blocage ») restent citees - via `_is_cited`. + via `_is_cited` ; + (c) le verdict `**HOLD ...**` en TETE de corps (#13779). HOLD est le + verbe que variation-protocol.md §3 donne au coordinateur (« HOLD + sans remplacement = echec coordinateur ») — un gate de merge qui + ignore le verbe de l'instrument qui le pilote est aveugle a son + propre pilote. Pose plus stricte que (b) — debut de corps ET + majuscule, cf HOLD_HEAD — parce que « hold » est un mot ordinaire. La levée d'un blocage passe par les formes canoniques — LIFT_MARKER reconnu (donc classify -> None dans la branche levee, cf `classify`) ou @@ -1154,6 +1189,9 @@ def _block_emitted(body: str) -> bool: uhead = head.upper() if head.lstrip(" \t*_").upper().startswith("[OVERRIDE]"): return False + m_hold = HOLD_HEAD.match(head) + if m_hold and not _lift_participle_after(head, m_hold.end()): + return True for marker in ("BLOCAGE", "BLOCK"): pos = 0 while (i := uhead.find(marker, pos)) != -1: @@ -1162,13 +1200,7 @@ def _block_emitted(body: str) -> bool: if before.isalnum() or before == "_" or before == "[" or after.isalnum() or after == "_": pos = i + len(marker) continue - tail = head[i + len(marker):i + len(marker) + 10].lstrip(" \t:;,.)('\"-—*") - word = "" - for ch in tail: - if not ch.isalpha(): - break - word += ch - if word.lower() in ("leve", "levee", "lifted"): + if _lift_participle_after(head, i + len(marker)): pos = i + len(marker) continue if not _is_cited(normalised[max(0, i - 30):i]): diff --git a/scripts/tests/test_check_unaddressed_nits_hold.py b/scripts/tests/test_check_unaddressed_nits_hold.py new file mode 100644 index 0000000000..781b7d29d1 --- /dev/null +++ b/scripts/tests/test_check_unaddressed_nits_hold.py @@ -0,0 +1,135 @@ +"""Tests #13779 facette B — le gate B.0 et le verbe HOLD du coordinateur. + +Defaut mesure : `.claude/rules/variation-protocol.md` §3 donne au coordinateur +UN verbe pour retenir une PR — **HOLD** (« HOLD sans remplacement = echec +coordinateur », « HOLD : citer la sortie de variation_light_cap.py »). Le gate, +lui, ne connaissait que BLOCAGE / BLOCK. Le post reel de myia-ai-01 sur #13712 +(2026-08-30T22:19:57Z) — « **HOLD coordinateur — un point du preflight reste +ouvert, et le gate ne le voit pas.** » — rendait donc `classify -> None` : un +gate de merge aveugle au verbe de l'instrument qui le pilote. + +Les tests sont ecrits par leurs FAUX NEGATIFS (un jeu de motifs se valide par +les formes qu'il doit attraper, jamais par ses hits, cf anti-regression.md) : +les cinq premiers ECHOUENT sur le code d'avant, les suivants sont des controles +qui passent des deux cotes et bornent l'elargissement. + +La pose de HOLD est PLUS STRICTE que celle de BLOCAGE, sur deux axes, parce que +« hold » est un mot ordinaire la ou « blocage » est deja presque toujours un +verdict : tete de CORPS (pas n'importe ou dans les 60 premiers chars) et +MAJUSCULE (la forme du protocole). Les controles ci-dessous fixent ces deux +bornes — sans elles, « je ne mets pas de hold sur cette PR » bloquerait. +""" + +import importlib.util +import sys +from pathlib import Path + +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) + +# Corps EXACT du commentaire myia-ai-01 du 2026-08-30T22:19:57Z sur la PR #13712 +# (tete seule : la suite est le detail du preflight, sans effet sur la pose). +FIXTURE_13712_HOLD = ( + "**HOLD coordinateur — un point du preflight reste ouvert, " + "et le gate ne le voit pas.**" +) + + +# --- Les cinq formes que l'organe ratait (echouent sur le code d'avant) --- + +def test_13779_hold_reel_de_13712_est_une_emission(): + assert mod._block_emitted(FIXTURE_13712_HOLD) is True + + +def test_13779_hold_reel_de_13712_est_classe_block(): + assert mod.classify("myia-ai-01", FIXTURE_13712_HOLD) == "BLOCK" + + +def test_13779_marqueur_hold_lane_en_tete_de_ligne(): + body = """Preflight en cours, deux points a verifier. + +[HOLD] lane myia-po-2025:CoursIA — budget LIGHT epuise (3 pour 6 grains). +""" + assert mod._block_emitted(body) is True + + +def test_13779_hold_en_titre_markdown(): + body = """## HOLD coordinateur + +Le plancher G-VAR-1 n'est pas tenu : le genre est META. +""" + assert mod._block_emitted(body) is True + + +def test_13779_hold_nu_sans_emphase(): + body = "HOLD — je tiens cette PR le temps que la lane nomme son remplacant." + assert mod._block_emitted(body) is True + + +# --- Controles : les bornes de l'elargissement (passent des deux cotes) --- + +def test_13779_hold_minuscule_en_prose_ne_pose_rien(): + # Sans la borne de CASSE, ce corps bloquerait la PR. + body = "hold on, je regarde le rapport d'echec avant de me prononcer." + assert mod._block_emitted(body) is False + + +def test_13779_hold_nomme_en_milieu_de_phrase_ne_pose_rien(): + # Sans la borne de POSITION, ce corps — qui dit l'exact contraire — + # bloquerait la PR : le mot tombe dans les 60 premiers caracteres. + body = "Je ne mets pas de HOLD sur cette PR, elle est saine. Je merge." + assert mod._block_emitted(body) is False + + +def test_13779_hold_leve_est_une_levee_pas_une_emission(): + body = "**HOLD leve** — la lane a nomme son grain de contenu, je merge." + assert mod._block_emitted(body) is False + + +def test_13779_hold_lifted_anglais_est_une_levee(): + body = "HOLD lifted — the coordinator named the replacement grain." + assert mod._block_emitted(body) is False + + +def test_13779_override_en_tete_reste_exempte(): + # L'override est l'etage superieur du protocole (#11639) : un arbitrage qui + # NOMME le hold qu'il leve ne doit pas se re-bloquer lui-meme. + body = "[OVERRIDE] lane myia-po-2025:CoursIA — HOLD coordinateur leve, budget recompte." + assert mod._block_emitted(body) is False + + +def test_13779_hold_cite_en_blockquote_ne_pose_rien(): + body = """> **HOLD coordinateur — budget LIGHT epuise.** + +C'est traite : le grain est requalifie MED/notebook-python, cf commit 24799875f. +""" + assert mod._block_emitted(body) is False + + +def test_13779_marqueur_hold_lane_en_backticks_ne_pose_rien(): + body = "Le protocole ecrit `[HOLD] lane ` — je documente la forme." + assert mod._block_emitted(body) is False + + +def test_13779_frontiere_de_mot_holder_ne_pose_rien(): + body = "HOLDER de la lane, je confirme que le grain est pris." + assert mod._block_emitted(body) is False + + +# --- Regression : les deux formes historiques sont intactes --- + +def test_13779_blocage_verdict_en_tete_emet_toujours(): + assert mod._block_emitted("**BLOCAGE — le preflight n'est pas passe.**") is True + + +def test_13779_blocage_lane_emet_toujours(): + assert mod._block_emitted("[BLOCAGE] lane myia-po-2023:CoursIA — scope non tenu.") is True + + +def test_13779_blocage_leve_ne_pose_toujours_rien(): + assert mod._block_emitted("**BLOCAGE leve** — le point est traite, je merge.") is False