From 9043bd3353b431a7d98adf21eabfc2533d026578 Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 26 Aug 2026 12:13:08 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(guards,#13083):=20BLOCAGE=20coordinateu?= =?UTF-8?q?r=20reconnu=20par=20le=20gate=20=E2=80=94=20symetrique=20non=20?= =?UTF-8?q?traite=20de=20#11639?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detecte un blocage ecrit par le coordinateur : marqueur `[BLOCAGE] lane` / `[BLOCK] lane` pose en tete de ligne (forme stricte #13030, jamais pose par une citation) ou verdict `**BLOCAGE ...**` en tete de corps. Le blocage echouait par un adverbe intercale (« avant TOUT merge » ratait la sous-chaine « avant merge »). kind BLOCK distinct, leve uniquement par l'arbitrage ecrit `[OVERRIDE] lane` ou l'emetteur reel hors self-review cap — jamais par l'auteur de la PR ni par un compte a la fois emetteur et auteur de la PR. Gate rc=1 sur #12942 et #12946, commentaire existant non modifie. Co-Authored-By: Claude-Code --- scripts/check_unaddressed_nits.py | 91 +++++++++++++ scripts/tests/test_check_unaddressed_nits.py | 129 +++++++++++++++++++ 2 files changed, 220 insertions(+) diff --git a/scripts/check_unaddressed_nits.py b/scripts/check_unaddressed_nits.py index 7313bfd53a..18bc8833ad 100644 --- a/scripts/check_unaddressed_nits.py +++ b/scripts/check_unaddressed_nits.py @@ -45,6 +45,14 @@ la borne tient pour tout autre tiers, et un override POST-merge ne peut pas avoir eteint une reserve avant la decision de merge. +Le symetrique — #13083 : un coordinateur qui BLOQUE, direction que #11639 ne +traitait pas. Un blocage (`[BLOCAGE] lane ...` pose en tete de ligne, ou le +verdict `**BLOCAGE ...**` en tete de corps) ne se leve ni par l'auteur de la +PR ni par un compte qui, sous le self-review cap, est a la fois emetteur et +auteur : seuls l'arbitrage ecrit `[OVERRIDE] lane` ou l'emetteur reel (compte +distinct de l'auteur PR) levent. Detection structurelle, jamais une +sous-chaine — un adverbe intercale (« avant TOUT merge ») ne la rate pas. + Ne levent RIEN : - **un commit pousse apres le nit** : un push muet est indiscernable d'un push qui repond (sur #10761, le « traitement » etait un rebase qui n'adressait @@ -117,6 +125,14 @@ COORDINATOR_LOGINS = {"myia-ai-01", "jsboige"} OVERRIDE_LANE = re.compile(r"\[OVERRIDE\]\s+lane\s+\S+") +# #13083 — le symetrique de #11639 : un coordinateur BLOQUE aussi, et l'organe +# ne le modelisait pas. Même contrainte de pose stricte que le durcissement de +# la citation (#13030) : le marqueur est POSE — ancre debut de ligne, hors +# backticks ni liste a puces — il n'est jamais POSE par une citation en milieu +# de phrase. 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+") + # 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 ») # complete `CHANGES_REQUESTED` (nom) : Hermes self-bot est force a state:COMMENTED @@ -714,6 +730,51 @@ def has_live_marker(body: str, markers: tuple[str, ...]) -> bool: return False +def _block_emitted(body: str) -> bool: + """Le coordinateur POSE-t-il un blocage (verdict, jamais une citation) ? + + #13083 — le defaut mesure : la prose « il doit etre leve par une phrase + AVANT TOUT merge » ratait la sous-chaine « avant merge » a cause du mot + intercale — un marqueur de sous-chaine ne survit pas a un adverbe. Deux + formes STRUCTURELLES, aucune dependante d'un mot : + + (a) le marqueur de protocole `[BLOCAGE] lane ` / + `[BLOCK] lane ...`, pose en tete de ligne — ancre `^` re.M et rejet + des backticks, meme pose stricte que #13030 pour `[OVERRIDE] lane` : + citer le marqueur (en liste, en prose, en backticks) ne le POSE pas ; + (b) le verdict `**BLOCAGE ...**` en TETE du corps (60 premiers chars) — + 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`. + + La levée d'un blocage passe par les formes canoniques — LIFT_MARKER + reconnu (donc classify -> None dans la branche levee, cf `classify`) ou + `[OVERRIDE] lane` (arbitrage #11639 en `analyse`). Un corps qui commence + par BLOCAGE sans forme de levee reste un signal : sur-blocquer est le bon + defaut d'un gate de merge, le sous-blocage est la dechirure que #13083 + ferme. + """ + stripped = _strip_quoted(body) + normalised = _unaccent(stripped) + if BLOCAGE_LANE.search(normalised): + return True + head = normalised[:60] + uhead = head.upper() + for marker in ("BLOCAGE", "BLOCK"): + pos = 0 + while (i := uhead.find(marker, pos)) != -1: + before = head[i - 1] if i > 0 else "" + after = head[i + len(marker)] if i + len(marker) < len(head) else "" + if before.isalnum() or before == "_" or after.isalnum() or after == "_": + pos = i + len(marker) + continue + if not _is_cited(normalised[max(0, i - 30):i]): + return True + pos = i + len(marker) + return False + + def ts(value: str | None) -> datetime | None: if not value: return None @@ -791,6 +852,15 @@ def classify(author: str, body: str) -> str | None: # (construction conditionnelle « et je merge » : voir _lift_cancelled — # l'annonce conditionnee n'est pas une levee, SAUF levee explicite d'auteur # en amont (#12074) ; le commentaire continue sinon) + # #13083 — un BLOCAGE coordinateur, le symetrique non traite de #11639 : + # l'organe modelisait un coordinateur qui LEVE (trappe [OVERRIDE] lane), + # pas un coordinateur qui BLOQUE. Traite a part de CONCERN_MARKERS : le + # blocage ne se leve PAS par les regles des nits ordinaires (cf `analyse`, + # lieu de la borne stricte) — le mettre dans la liste le downgraderait en + # reserve levable trop facilement. La detection est structurelle, pas une + # sous-chaine (cf `_block_emitted`). + if _block_emitted(body): + return "BLOCK" if has_live_marker(body, (VERDICT_POSITIVE,)): return None # verdict structurel positif rendu : il decide, la prose ne compte plus # #11636 : la recherche porte le body nettoye de ses verdicts MENTIONNES — @@ -861,6 +931,7 @@ def analyse(pr_data: dict, threads: list[dict], cutoff: datetime) -> dict: commits = [ts(c.get("committedDate")) for c in (pr_data.get("commits") or [])] commits = [c for c in commits if c] last_commit = max(commits) if commits else None + pr_author = (pr_data.get("author") or {}).get("login", "") # #11145 — borne d'auteur, durcie par #12836 : seule une levee de l'auteur # de la reserve compte. #12798 a montre pourquoi PR_AUTHOR n'est pas une @@ -989,6 +1060,26 @@ def _lift_eligible(lift_author: str, nit_author: str, ) if lifted: continue + # #13083 — les bornes strictes du blocage. Un blocage ne se leve ni + # par l'auteur de la PR, ni par une phrase de levee d'un compte qui est + # a la fois auteur de la PR et emetteur du blocage : sous le self-review + # cap (#12319) nit_author == pr_author == "jsboige" rendait la borne + # d'auteur #11145 vacue — la PR se debloquait elle-meme en repondant + # une phrase de levee. Seuls ouverts : l'arbitrage ECRIT `[OVERRIDE] + # lane` d'un coordinateur (mecanique #11639) et la re-review APPROVED + # de l'emetteur (etat natif), plus l'emetteur reel quand son compte est + # DISTINCT de l'auteur de la PR. + elif kind == "BLOCK": + if (any( + when < t < cutoff + and _lift_eligible(lift_author, login, lift_body) + and (lift_author != pr_author + or bool(OVERRIDE_LANE.search(lift_body))) + for (t, lift_author, lift_body) in explicit_lifts) + or any( + when < t < cutoff and author == login + for (t, author, _) in approved_rereviews)): + continue # #12319 : meme regime pour un nit porte par un commentaire ou une # review COMMENTED (dont chaque reserve Hermes, self-review cap). # Avant : n'importe quel commentaire posterieur de la lane levait diff --git a/scripts/tests/test_check_unaddressed_nits.py b/scripts/tests/test_check_unaddressed_nits.py index f35d0650be..5b0caea6ce 100644 --- a/scripts/tests/test_check_unaddressed_nits.py +++ b/scripts/tests/test_check_unaddressed_nits.py @@ -1905,3 +1905,132 @@ def test_12335_date_changerequested_reste_live(): "L'agent n'a pas repondu depuis." ) assert mod.classify("jsboige", body) == "BOT-CONCERN" + + +# --------------------------------------------------------------------------- +# #13083 — le BLOCAGE coordinateur, symetrique non traite de #11639. L'organe +# modelisait un coordinateur qui LEVE (trappe [OVERRIDE] lane), pas un qui +# BLOQUE. Mesure du 2026-08-26 : `**BLOCAGE MERGE (ai-01)**` sur #12942/#12946 +# -> classify None -> gate OK (controle de l'instrument). Le blur : « avant TOUT +# merge » rate la sous-chaine « avant merge » — un marqueur structure ne se +# rate pas par un adverbe. Le fix : marqueur `[BLOCAGE] lane` / `[BLOCK] lane` +# pose en tete de ligne (forme stricte #13030) + verdict BLOCAGE/BLOCK en tete +# de corps ; kind "BLOCK" distinct et levée strictement encadree (jamais par +# l'auteur de la PR, jamais par un compte qui emet et est auteur de la PR — +# self-review cap #12319 —, seulement par l'arbitrage ecrit [OVERRIDE] lane ou +# l'emetteur reel hors self-cap, ou sa re-review APPROVED). +# --------------------------------------------------------------------------- + + +def test_13083_prose_blocage_reelle_est_vue(): + """#13083 critere 1 (controle positif) : la prose REELLE du blocage + (commentaire 2026-08-26 sur #12942, non modifie) sort le gate du silence. + Avant le fix : classify -> None (« avant merge » rate par le « tout » + intercale dans « avant tout merge ») et le gate rendait OK. Apres : le + verdict BLOCAGE en tete du corps est un signal BLOCK a part entiere.""" + body = ("**BLOCAGE MERGE (ai-01)** — defaut de **chemin**, pas de substance. " + "Le travail scientifique est bon et je ne demande rien dessus ; c'est " + "un `git mv` qui separe cette PR du merge. (Poste en commentaire : " + "GitHub refuse `--request-changes` sur une PR du compte actif — le " + "blocage vaut au titre de B.0, il doit etre leve par une phrase " + "avant tout merge.)") + assert mod.classify("myia-ai-01", body) == "BLOCK" + + +def test_13083_marqueur_structurel_ne_se_rate_pas_par_un_adverbe(): + """#13083 — le besoin STRUCTUREL de l'issue en toutes lettres : le marqueur + `[BLOCAGE] lane` ne depend d'aucune sous-chaine, donc d'aucun mot intercale. + Peu importe la phrase qui suit, le marqueur pose le signal.""" + body = ("[BLOCAGE] lane myia-ai-01:CoursIA — il doit etre leve par une " + "phrase avant tout merge, n'importe quelle phrase.") + assert mod.classify("myia-ai-01", body) == "BLOCK" + body_en = ("[BLOCK] lane myia-ai-01:CoursIA — path defect, must be fixed " + "before any merge.") + assert mod.classify("myia-ai-01", body_en) == "BLOCK" + + +def test_13083_blocage_lane_non_levable_par_l_auteur_pr(): + """#13083 critere 2 : un `[BLOCAGE] lane` pose par un coordinateur ne se + leve pas par une phrase de levee de l'auteur de la PR — la borne d'auteur + #11145/#12836 tient mot pour mot pour un blocage (documenter un fix n'est + pas confirmer a la place de celui qui bloque).""" + blocage = {"author": {"login": "myia-ai-01"}, "createdAt": at(10), + "body": "[BLOCAGE] lane myia-ai-01:CoursIA — le chemin est mauvais, " + "il doit etre leve par une phrase avant tout merge."} + author_lift = {"author": {"login": "jsboige"}, "createdAt": at(12), + "body": "Les 2 points sont adresses : chemin corrige, commit abc."} + assert run([blocage, author_lift])["blocked"] is True + + +def test_13083_blocage_lane_self_cap_non_levable_par_le_meme_compte(): + """#13083 critere 2, cas severe : blocage POSE et PR AUTHOR sur le MEME + compte jsboige (self-review cap #12319). La borne d'auteur #11145 est alors + vacue (nit_author == pr_author == "jsboige") — sans le garde dedie, une + phrase de levee du compte pr leverait son PROPRE blocage. Seul l'arbitrage + ecrit le leve (cf test suivant).""" + blocage = {"author": {"login": "jsboige"}, "createdAt": at(10), + "body": "[BLOCAGE] lane myia-ai-01:CoursIA — chemin mauvais."} + self_lift = {"author": {"login": "jsboige"}, "createdAt": at(12), + "body": "Les 2 points sont adresses : chemin corrige."} + assert run([blocage, self_lift])["blocked"] is True + + +def test_13083_override_lane_leve_le_blocage(): + """#13083 critere 3 : un `[OVERRIDE] lane` posterieur d'un coordinateur + leve le blocage qu'il arbitre — la mecanique #11639, coherence #13030. + Le blocage est pose par un worker (lane CoursIA-2), l'arbitrage par ai-01.""" + blocage = {"author": {"login": "myia-po-2023"}, "createdAt": at(10), + "body": "[BLOCK] lane myia-po-2023:CoursIA-2 — l'arbre est sale, " + "pas de merge tant que le drift n'est pas regle."} + override = {"author": {"login": "myia-ai-01"}, "createdAt": at(12), + "body": OVERRIDE_BODY} + assert run([blocage, override])["blocked"] is False + + +def test_13083_emetteur_hors_self_cap_leve_son_blocage(): + """#13083 — le pendant de l'issue (« seulement par son emetteur ») : + l'emetteur dont le compte est DISTINCT de l'auteur de la PR leve son propre + blocage par une phrase de levee — borne d'auteur standard, rien de plus.""" + blocage = {"author": {"login": "myia-ai-01"}, "createdAt": at(10), + "body": "[BLOCAGE] lane myia-ai-01:CoursIA — chemin mauvais."} + emetter_lift = {"author": {"login": "myia-ai-01"}, "createdAt": at(12), + "body": "Levee de mon blocage : chemin corrige, commit abc."} + assert run([blocage, emetter_lift])["blocked"] is False + + +def test_13083_marqueur_cite_ne_pose_pas(): + """#13083 garde-fou (forme #13030) : citer `[BLOCAGE] lane x` en liste ou + en backticks ne POSE pas le marqueur — un geste qui documente le mecanisme + ne bloque pas le merge (le miroir exacter du desarmement #13030, cote + sur-blocage cette fois).""" + body = ("- **(b)** `[BLOCAGE] lane myia-po-2023:CoursIA-2` serait le marqueur " + "(discussion de design).") + assert mod.classify("myia-ai-01", body) is None + + +def test_13083_negation_immediate_ne_declenche_pas(): + """#13083 garde-fou : « Pas de blocage de ma part » ne pose rien — le citer + « pas de » neutralise l'occurrence (meme hygiene `_is_cited` que les + CONCERN_MARKERS).""" + assert mod.classify("myia-ai-01", "Pas de blocage de ma part, LGTM.") is None + assert mod.classify("myia-ai-01", "No block from my side.") is None + + +def test_13083_narration_pas_un_blocage_en_section_ne_declenche_pas(): + """#13083 borne de la position : la narration « pas un blocage » vit en + SECTION (hors 60 premiers chars) — elle ne declare rien, et le registre du + nit (« a changer », en tete de corps) reste, lui, detecte (BOT-CONCERN, + fixture #11190 inchangee).""" + body = ("Une seule chose a changer — une ligne.\n\n" + "Contexte de la veille, paragraphe de remplissage de reserve.\n\n" + "## Ce qui reste — pas un blocage, c'est le grain suivant.") + assert mod.classify("myia-ai-01", body) == "BOT-CONCERN" + + +def test_13083_blocage_dans_un_verdict_mention_ne_declenche_pas(): + """#13083 garde-fou : un verdict positif (APPROVE) qui nomme le BLOCAGE + d'un autre cycle dans sa narration reste positif — la mention « leve par » + (pattern #11809, reference pointable) neutralise l'occurrence.""" + body = ("APPROVE — le BLOCAGE leve par le commit 06956bd0a, chemin corrige. " + "**Mergée.**") + assert mod.classify("myia-ai-01", body) is None From 2d202ec004da6d0e901e7ed3cd4ad371ea3f7a8b Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 26 Aug 2026 12:26:30 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(guards,#13083):=20durcissement=20post-r?= =?UTF-8?q?eview=20Hermes=20=E2=80=94=20ancrage=20BLOCAGE=5FLANE=20strict?= =?UTF-8?q?=20^\s*\[=20+=20neutralisation=20[BLOCAGE]=20crochets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review Hermes sur PR #13093 : la classe de prefixe [\s*_#]* acceptait `* [BLOCAGE] lane x` en liste a puces markdown, contredisant la doc « rejet des lists a puces ». La forme est durcie a l'ancrage strict `^\s*\[` (seule l'indentation est toleree; backticks/puces/blockquote exclus), et la 2e branche de _block_emitted neutralise desormais toute occurrence entre crochets (before == "[") — un `[BLOCAGE]` mentionne ou cite releve de la forme marqueur, seule la branche 1 (BLOCAGE_LANE pose) le leve. Le verdict gras `**BLOCAGE ...**` en tete de corps reste POSE (before == "*"). Test dedie ajoute: test_13083_puce_etoile_ne_pose_pas. 177 tests passent (176 + 1). Gate live rechappe: rc=1 BLOCKED sur #12942/#12946. Co-Authored-By: Claude-Code --- scripts/check_unaddressed_nits.py | 15 +++++++++------ scripts/tests/test_check_unaddressed_nits.py | 10 ++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/scripts/check_unaddressed_nits.py b/scripts/check_unaddressed_nits.py index 18bc8833ad..b7aeb96730 100644 --- a/scripts/check_unaddressed_nits.py +++ b/scripts/check_unaddressed_nits.py @@ -127,11 +127,14 @@ # #13083 — le symetrique de #11639 : un coordinateur BLOQUE aussi, et l'organe # ne le modelisait pas. Même contrainte de pose stricte que le durcissement de -# la citation (#13030) : le marqueur est POSE — ancre debut de ligne, hors -# backticks ni liste a puces — il n'est jamais POSE par une citation en milieu -# de phrase. 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+") +# la citation (#13030) : le marqueur est POSE — ancre debut de ligne (seule +# indentation toleree), hors backticks, hors puces markdown (`* `, `- `), hors +# blockquote (`>`), il n'est jamais POSE par une citation en milieu de phrase. +# 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+") # 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 ») @@ -766,7 +769,7 @@ def _block_emitted(body: str) -> bool: while (i := uhead.find(marker, pos)) != -1: before = head[i - 1] if i > 0 else "" after = head[i + len(marker)] if i + len(marker) < len(head) else "" - if before.isalnum() or before == "_" or after.isalnum() or after == "_": + if before.isalnum() or before == "_" or before == "[" or after.isalnum() or after == "_": pos = i + len(marker) continue if not _is_cited(normalised[max(0, i - 30):i]): diff --git a/scripts/tests/test_check_unaddressed_nits.py b/scripts/tests/test_check_unaddressed_nits.py index 5b0caea6ce..571c56d647 100644 --- a/scripts/tests/test_check_unaddressed_nits.py +++ b/scripts/tests/test_check_unaddressed_nits.py @@ -2008,6 +2008,16 @@ def test_13083_marqueur_cite_ne_pose_pas(): assert mod.classify("myia-ai-01", body) is None +def test_13083_puce_etoile_ne_pose_pas(): + """#13083 garde-fou (comment Hermes, PR #13093) : `* [BLOCAGE] lane x` en + liste a puces markdown ne POSE pas — l'etoile de deco n'est PAS dans + l'ancrage `^\\s*` (seule l'indentation est toleree). La forme verdict-gras + `**BLOCAGE ...**` reste couverte par la 2e branche de _block_emitted.""" + body = ("* [BLOCAGE] lane myia-po-2023:CoursIA-2 — liste a puces, " + "documentation du mecanisme, pas un blocage.") + assert mod.classify("myia-ai-01", body) is None + + def test_13083_negation_immediate_ne_declenche_pas(): """#13083 garde-fou : « Pas de blocage de ma part » ne pose rien — le citer « pas de » neutralise l'occurrence (meme hygiene `_is_cited` que les From b14e68e38376d1ae2ace848e3680a234c91c70f8 Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 26 Aug 2026 13:46:49 +0200 Subject: [PATCH 3/3] fix(guards,#13083): override naturel nommant le blocage ne re-bloque plus (bornes A/B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preflight ADJOINT po-2025 (2026-08-26) : un [OVERRIDE] naturel disant « Blocage leve par override » etait reclasse BLOCK avant l'etage de levee — mesure : blocked=True sur le seul post d'arbitrage (le coordinateur bloquait la PR en la debloquant), et le post etait exclu d'explicit_lifts. Deux bornes scopees a la forme verdict en tete (le marqueur structurel [BLOCAGE] lane reste absolu) : - A. un post d'ARBITRAGE ([OVERRIDE] pose en tete) n'emet jamais ; - B. « BLOCAGE leve/levee/lifted ... » = mention (complement d'une levee), miroir post-fenetre de _is_cited. 157/157 tests (base main 142 + 15). Controle positif live tenu : check_unaddressed_nits 12942 -> rc=1, 12946 -> rc=1 (prose inchangee). Non-regression croisee : mention + pick_idle_grain 70/70. Co-Authored-By: Claude-Code --- scripts/check_unaddressed_nits.py | 27 ++++++++++++ scripts/tests/test_check_unaddressed_nits.py | 45 ++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/scripts/check_unaddressed_nits.py b/scripts/check_unaddressed_nits.py index b7aeb96730..3d8e4322e8 100644 --- a/scripts/check_unaddressed_nits.py +++ b/scripts/check_unaddressed_nits.py @@ -757,6 +757,22 @@ def _block_emitted(body: str) -> bool: par BLOCAGE sans forme de levee reste un signal : sur-blocquer est le bon defaut d'un gate de merge, le sous-blocage est la dechirure que #13083 ferme. + + Reparation preflight ADJOINT (po-2025, 2026-08-26) : un override NATUREL + qui nomme le blocage qu'il leve (« [OVERRIDE] lane x — Blocage leve par + override ») etait reclasse BLOCK avant l'etage de levee — mesure : le post + d'arbitrage devenait lui-meme un signal BLOCK (et etait exclu + d'explicit_lifts par classify != None), la prose meme qui arbitrait + re-bloquait le gate. Deux bornes, toutes deux scopees a la forme verdict + en tete (b) ; le marqueur structurel (a) reste absolu — un arbitre qui + veut re-bloquer le pose delibereement : + + A. un post d'ARBITRAGE (`[OVERRIDE]` pose en tete du corps) n'emet + jamais — l'override est l'etage superieur du protocole (#11639) ; + B. « BLOCAGE leve / levee / lifted ... » : l'occurrence immédiatement + suivie d'un participe de levée est le COMPLÉMENT d'une levée + (mention #11636), pas une émission — miroir post-fenêtre de + `_is_cited`. """ stripped = _strip_quoted(body) normalised = _unaccent(stripped) @@ -764,6 +780,8 @@ def _block_emitted(body: str) -> bool: return True head = normalised[:60] uhead = head.upper() + if head.lstrip(" \t*_").upper().startswith("[OVERRIDE]"): + return False for marker in ("BLOCAGE", "BLOCK"): pos = 0 while (i := uhead.find(marker, pos)) != -1: @@ -772,6 +790,15 @@ 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"): + pos = i + len(marker) + continue if not _is_cited(normalised[max(0, i - 30):i]): return True pos = i + len(marker) diff --git a/scripts/tests/test_check_unaddressed_nits.py b/scripts/tests/test_check_unaddressed_nits.py index 571c56d647..246add6be8 100644 --- a/scripts/tests/test_check_unaddressed_nits.py +++ b/scripts/tests/test_check_unaddressed_nits.py @@ -2044,3 +2044,48 @@ def test_13083_blocage_dans_un_verdict_mention_ne_declenche_pas(): body = ("APPROVE — le BLOCAGE leve par le commit 06956bd0a, chemin corrige. " "**Mergée.**") assert mod.classify("myia-ai-01", body) is None + + +def test_13083_override_naturel_nommant_le_blocage_ne_rebloque_pas(): + """#13083 correctif preflight ADJOINT (po-2025, 2026-08-26), borne A : un + override NATUREL « [OVERRIDE] lane x — Blocage leve par override » etait + reclasse BLOCK avant l'etage de levee — mesure pre-fix : classify='BLOCK', + le post d'arbitrage devenait lui-meme un signal bloquant. L'arbitrage est + l'etage superieur du protocole (#11639) : pose en tete, il n'emet jamais.""" + body = ("[OVERRIDE] lane myia-ai-01:CoursIA — Blocage leve par override, " + "chemin corrige.") + assert mod._block_emitted(body) is False + assert mod.classify("myia-ai-01", body) is None + + +def test_13083_mention_blocage_leve_en_tete_ne_pose_pas(): + """#13083 correctif preflight ADJOINT, borne B : « Blocage leve par + override » SANS marqueur [OVERRIDE] — l'occurrence est le COMPLEMENT d'une + levee (mention #11636), pas une emission. Miroir post-fenetre de _is_cited + (qui ne regarde que les 30 chars AVANT l'occurrence).""" + assert mod.classify("myia-ai-01", + "Blocage leve par override : chemin corrige.") is None + assert mod.classify("myia-ai-01", "Blocage levee.") is None + assert mod.classify("myia-ai-01", + "BLOCK lifted by override, path fixed.") is None + + +def test_13083_override_naturel_seul_ne_bloque_pas_la_pr(): + """#13083 correctif preflight ADJOINT, niveau organe : sur une PR SANS + blocage prealable, l'override naturel d'un coordinateur (arbitrage d'un + nit ordinaire) ne doit pas BLOQUER la PR. Mesure pre-fix : blocked=True sur + le seul post d'arbitrage — le coordinateur bloquait la PR en la debloquant.""" + override = {"author": {"login": "myia-ai-01"}, "createdAt": at(12), + "body": "[OVERRIDE] lane myia-ai-01:CoursIA — Blocage leve " + "par override, chemin corrige."} + assert run([override])["blocked"] is False + + +def test_13083_blocage_reel_conditionnel_a_un_override_reste_block(): + """#13083 garde-fou du correctif preflight : un VRAI blocage dont la prose + nomme l'override ATTENDU (« tant que [OVERRIDE] lane n'est pas pose ») + reste un blocage — la borne A ne desarme que le post qui COMMENCE par + l'arbitrage, jamais celui qui commence par le verdict BLOCAGE.""" + body = ("**BLOCAGE MERGE (ai-01)** — pas de merge tant que [OVERRIDE] lane " + "n'est pas pose par le coordinateur.") + assert mod.classify("myia-ai-01", body) == "BLOCK"