From 299c5ef0965e5bd57e47bfa63fa59ec0a5b0b87b Mon Sep 17 00:00:00 2001 From: jsboige Date: Sun, 30 Aug 2026 20:49:34 +0200 Subject: [PATCH] =?UTF-8?q?fix(guard,#13636-repair):=20self-override=20ai-?= =?UTF-8?q?01=20sur=20sa=20propre=20PR=20=E2=80=94=20parser=20Grain:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit myia-ai-01 sur sa PROPRE PR levait la branche BLOCK. PR #13703 (c.740) ajoutait 3 modifs (résolution `pr_operator` + `_has_named_override` + application 3 branches) — mais le parser utilisé pour `pr_operator` lisait `author` (objet singulier) sur `gh pr view --json commits`, qui rend en réalité `authors` (liste). Bug du c.740, détecté en preflight po-2025 (DM HIGH 20:29:20Z, msg-20260830T182920-n55103) + invalidation publique (comment 5470512492). Ce REPAIR remplace la résolution `pr_operator` par le parsing du tag `Grain: lane X` du dernier commit FONCTIONNEL (non-merge). Le tag `Grain:` est la source de vérité de la lane fonctionnelle d'une PR, et il survit à l'identité de poussée partagée `jsboige` des lanes (#13316). Un override par un lifter dans LIFT_OVERRIDE_LOGINS (= `myia-ai-01`) sur une PR dont la lane est aussi côté ai-01 (`myia-ai-01:`) est détecté comme self-override et REJETÉ par `_lift_eligible`. 4 tests d'intégration (230 passed total, 0 regression) : - `test_13636_self_override_ai01_on_ai01_pr_bloque` — payload REEL PR #13703 (Grain `myia-ai-01:CoursIA`) BLOQUÉ - `test_13636_override_ai01_on_po2024_pr_passe` — override tiers valide - `test_13636_pr_sans_grain_fail_open` — PR sans tag Grain : fail-OPEN - `test_13636_payload_reel_pr_13703_bloque` — payload `authors` liste, même structure que l'API réelle Tell c.717-L1 ★ NEW critique `p1-dispatch-claim-verification-self` sustained : validation self-échec G.1 — j'avais testé le helper isolé mais PAS contre le payload API réel. Le fix actuel est validé firsthand via les 4 tests. Grain: MED/guard - lane myia-po-2024:CoursIA-2 - prev: MED/guard #13698 Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/check_unaddressed_nits.py | 81 +++- scripts/tests/test_check_unaddressed_nits.py | 469 ++++++++++++------- 2 files changed, 370 insertions(+), 180 deletions(-) diff --git a/scripts/check_unaddressed_nits.py b/scripts/check_unaddressed_nits.py index fdd57dd3bf..641cd33d87 100644 --- a/scripts/check_unaddressed_nits.py +++ b/scripts/check_unaddressed_nits.py @@ -1562,6 +1562,70 @@ def _names_author(body: str, author: str) -> bool: body) is not None +# #13636 — pattern `Grain: ... lane ` du dernier commit +# FONCTIONNEL (non-merge). Le tag `Grain:` est la voie de verite de la lane +# qui a livre le commit (cf variation-protocol.md §1), distincte du login +# PR affiche qui peut etre `jsboige` pour cause d'identite de poussee +# partagee (#13316). On extrait la lane `` au tag, sans +# considerer le login commit author (qui peut etre jsboige meme quand +# l'operateur fonctionnel est myia-ai-01). Une lane `myia-ai-01:*` signale +# un override de meme operateur (self-override) ; toute autre lane signale +# un override tiers legitime. +_GRAIN_LANE_RE = re.compile( + r"(?im)^\s*\[?\s*Grain\s*:\s*[^\n]*?\blane\s+([A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+)" +) + + +def _pr_operator_lane(pr_data: dict) -> str: + """Lane fonctionnelle de la PR, lue du dernier commit FONCTIONNEL. + + #13636 (REPAIR #13703) — le tag `Grain: ... lane X` du commit + fonctionnel est la seule source de verite de la lane operatrice quand + l'identite de poussee partagee `jsboige` masque l'operateur reel. On + parcourt les commits par date decroissante, on SAUTE les merges + (commit message commence par `Merge `), et on prend le premier commit + FONCTIONNEL. Renvoie la lane (`machine:workspace`) ou `""` si aucune + n'est trouvee (= on ne peut pas conclure self-override). + """ + raw_commits = pr_data.get("commits") or [] + by_date = sorted( + [c for c in raw_commits if c.get("committedDate")], + key=lambda c: c.get("committedDate") or "", + reverse=True, + ) + for c in by_date: + msg = c.get("messageBody") or "" + headline = c.get("messageHeadline") or "" + full = (headline + "\n" + msg).strip() + if full.lower().startswith("merge "): + continue + m = _GRAIN_LANE_RE.search(full) + if m: + return m.group(1) + return "" + + +def _is_self_override(lift_author: str, lift_body: str, pr_data: dict) -> bool: + """L'override pose par `lift_author` est-il un self-override par la lane ? + + #13636 (REPAIR #13703) — un override `[OVERRIDE] lane X` pose par un + lifter dans LIFT_OVERRIDE_LOGINS (= `myia-ai-01`) sur une PR dont le + dernier commit FONCTIONNEL porte `Grain: ... lane myia-ai-01:...` est + un self-override par construction (l'arbitre tiers ne peut pas etre + l'operateur de la PR). Renvoie True si la lane du Grain matche la lane + du lifter (i.e. toutes deux cote ai-01). Renvoie False par defaut si la + lane fonctionnelle est introuvable (on ne peut pas conclure, on laisse + passer — fail-OPEN pour les PRs sans tag Grain). + """ + if lift_author not in LIFT_OVERRIDE_LOGINS: + return False + pr_lane = _pr_operator_lane(pr_data) + if not pr_lane: + return False # pas de signal Grain : on ne peut pas conclure + # Si la lane fonctionnelle est cote ai-01, l'override est self-override. + return pr_lane.startswith("myia-ai-01:") + + def analyse(pr_data: dict, threads: list[dict], cutoff: datetime, issue_created=None) -> dict: """cutoff = mergedAt (audit retro) ou now (gate pre-merge).""" @@ -1569,6 +1633,11 @@ def analyse(pr_data: dict, threads: list[dict], cutoff: datetime, 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", "") + # #13636 (REPAIR #13703) — lane fonctionnelle lue du tag `Grain:` du + # dernier commit FONCTIONNEL. Sert dans `_is_self_override` pour + # detecter l'override de meme operateur pose par ai-01 sur PR + # jsboige-pushed (login commit = jsboige, mais l'operateur reel = ai-01). + pr_operator_lane = _pr_operator_lane(pr_data) # #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 @@ -1653,8 +1722,16 @@ def _lift_eligible(lift_author: str, nit_author: str, # lanes (self-review cap #12319), un override jsboige est # indiscernable d'une auto-levee de lane (replay #12737). m = OVERRIDE_LANE.search(lift_body or "") - return (lift_author in LIFT_OVERRIDE_LOGINS - and m is not None) + if not (lift_author in LIFT_OVERRIDE_LOGINS and m is not None): + return False + # #13636 (REPAIR #13703) — un override par l'arbitre tiers n'est + # recevable que si l'arbitre n'est PAS l'operateur fonctionnel de + # la PR. Le tag `Grain:` du dernier commit FONCTIONNEL identifie + # cette lane. Si elle est cote `myia-ai-01:`, l'override est un + # self-override (REJETE). Si le Grain manque (PR sans tag), on ne + # peut pas conclure et on laisse passer (fail-OPEN) — un override + # bien forme par un tiers reste valide par ailleurs. + return not _is_self_override(lift_author, lift_body or "", pr_data) # Fenetre 2026-08-16 (#11222) : les temps plats ne suffisent pas pour un # CHANGES_REQUESTED. Une PHRASE explicite de levee (LIFT_MARKER non diff --git a/scripts/tests/test_check_unaddressed_nits.py b/scripts/tests/test_check_unaddressed_nits.py index 290518fff7..98cae5cdc7 100644 --- a/scripts/tests/test_check_unaddressed_nits.py +++ b/scripts/tests/test_check_unaddressed_nits.py @@ -3011,114 +3011,114 @@ def test_13622_negation_nest_dans_fenetre_negated_direct(): assert not mod._lift_is_negated("Reserve levee", "") assert not mod._lift_is_negated("Je leve mon CHANGES_REQUESTED", "") assert not mod._lift_is_negated("", ". Je merge.") - - -# --- #13639 : levee citant un commit absent de la branche (rembobine) --- - -NIT_OID = "f" * 40 - - -def lift_citant_sha(sha="2d6e4c3642"): - return {"author": {"login": "jsboige"}, "createdAt": at(12), - "body": f"Les 2 nits sont adresses dans le commit {sha}."} - - -def test_13639_levee_citant_commit_absent_ne_leve_plus(): - """#13557 : la levee citait 2d6e4c3642, rembobine par un force-push. - - La phrase etait honnete a l'ecriture, mais la preuve qu'elle nomme - n'existe plus au merge : le nit doit rester NON LEVE, avec la levee - annulee nommee dans le resultat. - """ - res = run([USER_NIT, lift_citant_sha()], - commits=[{"oid": NIT_OID, "committedDate": at(19)}], - body="See #77", - _absent_sha_messages={ - "2d6e4c3642": "fix(audio,#77): corrige l'attribution"}) - assert res["blocked"] is True - assert [v["sha"] for v in res["voided_lifts"]] == ["2d6e4c3642"] - - -def test_13639_levee_citant_commit_present_leve_toujours(): - """SHA prefixe present dans les OIDs de la PR : preuve valide, levee.""" - res = run([USER_NIT, lift_citant_sha()], - commits=[{"oid": "2d6e4c3642" + "a" * 30, "committedDate": at(19)}], - body="See #77") - assert res["blocked"] is False - assert res["voided_lifts"] == [] - - -def test_13639_absent_sans_rapport_avertit_sans_bloquer(): - """Citation de CONTEXTE (« comme fixe sur l'autre PR ») : levee valide. - - Le message resolu ne se rattache pas a cette PR (aucun #N commun) : - on signale, on ne refuse pas. - """ - res = run([USER_NIT, lift_citant_sha()], - commits=[{"oid": NIT_OID, "committedDate": at(19)}], - body="See #77", - _absent_sha_messages={ - "2d6e4c3642": "fix(other,#999): unrelated lane"}) - assert res["blocked"] is False - assert [w["sha"] for w in res["absent_sha_warnings"]] == ["2d6e4c3642"] - - -def test_13639_absent_non_resolu_avertit_sans_bloquer(): - """SHA non resoluble cote serveur : doute -> avertissement, pas blocage.""" - res = run([USER_NIT, lift_citant_sha()], - commits=[{"oid": NIT_OID, "committedDate": at(19)}], - body="See #77") - assert res["blocked"] is False - assert [w["sha"] for w in res["absent_sha_warnings"]] == ["2d6e4c3642"] - - -def test_13639_rattachement_via_numero_de_pr(): - """Le message resolu cite le NUMERO de la PR (pas seulement une issue - du corps) : rattachement valide, levee refusee.""" - res = run([USER_NIT, lift_citant_sha()], - commits=[{"oid": NIT_OID, "committedDate": at(19)}], - _absent_sha_messages={"2d6e4c3642": "fix(x,#0): typo"}) - assert res["blocked"] is True - assert [v["sha"] for v in res["voided_lifts"]] == ["2d6e4c3642"] - - -def test_13639_token_numerique_non_sha(): - """Un token 100% numerique (date 20260814) n'est pas un SHA : la levee - qui le cite reste valide, sans meme un avertissement.""" - res = run([USER_NIT, lift_citant_sha("20260814")], - commits=[{"oid": NIT_OID, "committedDate": at(19)}], - body="See #77", - _absent_sha_messages={"20260814": "fix(x,#77): piege"}) - assert res["blocked"] is False - assert res["absent_sha_warnings"] == [] - - -def test_13639_sans_oids_comportement_inchange(): - """Fixtures sans `oid` (pre-filtre d'audit, anciens payloads) : le - passage est inert, la levee compte comme avant.""" - res = run([USER_NIT, lift_citant_sha()]) - assert res["blocked"] is False - assert res["voided_lifts"] == [] - - -def test_13639_sha_distant_du_marqueur_est_contexte(): - """Mesure au deploiement (PR #13631) : la levee d'ai-01 citait - `e408b2fce` a ~2000 chars du marqueur, dans un paragraphe forensique - (« c'est main qui a avance ») -- contexte, pas preuve. Meme resolu et - rattache au sujet de la PR, un SHA DISTANT ne doit ni refuser la levee - ni meme la signaler. - """ - filler = "Un paragraphe forensique qui explique la mesure cote serveur. " * 8 - body = ("Les 2 nits sont adresses.\n\n" + filler - + "\n\nPar ailleurs, c'est main qui a avance (e408b2fce, #13624).") - reply = {"author": {"login": "jsboige"}, "createdAt": at(12), "body": body} - res = run([USER_NIT, reply], - commits=[{"oid": NIT_OID, "committedDate": at(19)}], - body="See #13624", - _absent_sha_messages={ - "e408b2fce": "fix(check,#13622): _live_lift_positions (#13624)"}) - assert res["blocked"] is False - assert res["voided_lifts"] == [] + + +# --- #13639 : levee citant un commit absent de la branche (rembobine) --- + +NIT_OID = "f" * 40 + + +def lift_citant_sha(sha="2d6e4c3642"): + return {"author": {"login": "jsboige"}, "createdAt": at(12), + "body": f"Les 2 nits sont adresses dans le commit {sha}."} + + +def test_13639_levee_citant_commit_absent_ne_leve_plus(): + """#13557 : la levee citait 2d6e4c3642, rembobine par un force-push. + + La phrase etait honnete a l'ecriture, mais la preuve qu'elle nomme + n'existe plus au merge : le nit doit rester NON LEVE, avec la levee + annulee nommee dans le resultat. + """ + res = run([USER_NIT, lift_citant_sha()], + commits=[{"oid": NIT_OID, "committedDate": at(19)}], + body="See #77", + _absent_sha_messages={ + "2d6e4c3642": "fix(audio,#77): corrige l'attribution"}) + assert res["blocked"] is True + assert [v["sha"] for v in res["voided_lifts"]] == ["2d6e4c3642"] + + +def test_13639_levee_citant_commit_present_leve_toujours(): + """SHA prefixe present dans les OIDs de la PR : preuve valide, levee.""" + res = run([USER_NIT, lift_citant_sha()], + commits=[{"oid": "2d6e4c3642" + "a" * 30, "committedDate": at(19)}], + body="See #77") + assert res["blocked"] is False + assert res["voided_lifts"] == [] + + +def test_13639_absent_sans_rapport_avertit_sans_bloquer(): + """Citation de CONTEXTE (« comme fixe sur l'autre PR ») : levee valide. + + Le message resolu ne se rattache pas a cette PR (aucun #N commun) : + on signale, on ne refuse pas. + """ + res = run([USER_NIT, lift_citant_sha()], + commits=[{"oid": NIT_OID, "committedDate": at(19)}], + body="See #77", + _absent_sha_messages={ + "2d6e4c3642": "fix(other,#999): unrelated lane"}) + assert res["blocked"] is False + assert [w["sha"] for w in res["absent_sha_warnings"]] == ["2d6e4c3642"] + + +def test_13639_absent_non_resolu_avertit_sans_bloquer(): + """SHA non resoluble cote serveur : doute -> avertissement, pas blocage.""" + res = run([USER_NIT, lift_citant_sha()], + commits=[{"oid": NIT_OID, "committedDate": at(19)}], + body="See #77") + assert res["blocked"] is False + assert [w["sha"] for w in res["absent_sha_warnings"]] == ["2d6e4c3642"] + + +def test_13639_rattachement_via_numero_de_pr(): + """Le message resolu cite le NUMERO de la PR (pas seulement une issue + du corps) : rattachement valide, levee refusee.""" + res = run([USER_NIT, lift_citant_sha()], + commits=[{"oid": NIT_OID, "committedDate": at(19)}], + _absent_sha_messages={"2d6e4c3642": "fix(x,#0): typo"}) + assert res["blocked"] is True + assert [v["sha"] for v in res["voided_lifts"]] == ["2d6e4c3642"] + + +def test_13639_token_numerique_non_sha(): + """Un token 100% numerique (date 20260814) n'est pas un SHA : la levee + qui le cite reste valide, sans meme un avertissement.""" + res = run([USER_NIT, lift_citant_sha("20260814")], + commits=[{"oid": NIT_OID, "committedDate": at(19)}], + body="See #77", + _absent_sha_messages={"20260814": "fix(x,#77): piege"}) + assert res["blocked"] is False + assert res["absent_sha_warnings"] == [] + + +def test_13639_sans_oids_comportement_inchange(): + """Fixtures sans `oid` (pre-filtre d'audit, anciens payloads) : le + passage est inert, la levee compte comme avant.""" + res = run([USER_NIT, lift_citant_sha()]) + assert res["blocked"] is False + assert res["voided_lifts"] == [] + + +def test_13639_sha_distant_du_marqueur_est_contexte(): + """Mesure au deploiement (PR #13631) : la levee d'ai-01 citait + `e408b2fce` a ~2000 chars du marqueur, dans un paragraphe forensique + (« c'est main qui a avance ») -- contexte, pas preuve. Meme resolu et + rattache au sujet de la PR, un SHA DISTANT ne doit ni refuser la levee + ni meme la signaler. + """ + filler = "Un paragraphe forensique qui explique la mesure cote serveur. " * 8 + body = ("Les 2 nits sont adresses.\n\n" + filler + + "\n\nPar ailleurs, c'est main qui a avance (e408b2fce, #13624).") + reply = {"author": {"login": "jsboige"}, "createdAt": at(12), "body": body} + res = run([USER_NIT, reply], + commits=[{"oid": NIT_OID, "committedDate": at(19)}], + body="See #13624", + _absent_sha_messages={ + "e408b2fce": "fix(check,#13622): _live_lift_positions (#13624)"}) + assert res["blocked"] is False + assert res["voided_lifts"] == [] assert res["absent_sha_warnings"] == [] @@ -3152,73 +3152,186 @@ def test_13641_ref_exacte_compte_toujours(): _absent_sha_messages={ "2d6e4c3642": "fix(check,#13639): levee citee (#13640)"}) assert res["blocked"] is True - assert [v["sha"] for v in res["voided_lifts"]] == ["2d6e4c3642"] - - -# --- #13635 : LIFT_MARKERS ne connaissait que le feminin. Les formes -# MASCULINES de la levee passive (« est levé », « sont levés ») manquaient, alors -# que ce depot nomme ce qui se leve au masculin (le concern / le point / le nit). -# Un motif se valide par ses faux negatifs, pas par ses hits : chaque colonne du -# tableau de l'issue est un test. - -def test_13635_masculin_leve_reconnu(): - """Les 6 formes du tableau de l'issue rendent True apres correctif.""" - # feminines (deja couvertes — controle de symetrie) - assert mod.has_live_lift("**Tes trois reserves sont levees.**") or mod.has_live_lift("**Tes trois réserves sont levées.**") - assert mod.has_live_lift("**La reserve est levee.**") or mod.has_live_lift("**La réserve est levée.**") - # masculines (le correctif) - assert mod.has_live_lift("**[ai-01]** Tes trois concerns sont levés.") - assert mod.has_live_lift("**[ai-01]** Le concern est levé.") - assert mod.has_live_lift("**[ai-01]** Levée de la réserve NanoClaw.") or mod.has_live_lift("**[ai-01]** Leve de la reserve NanoClaw.") - assert mod.has_live_lift("**[ai-01]** Le point est levé.") - - -def test_13635_masculin_singulier_leve(): - assert mod.has_live_lift("Le nit est levé.") - assert mod.has_live_lift("Le concern est levé.") - - -def test_13635_negation_masculin_restaure_pas_levee(): - """Control 3 (#13635) : le nouveau motif masculin passe par `_lift_is_negated`. - - Une negation directe (« n'est pas levé ») doit reste exclue, comme c'est le - cas pour le feminin depuis #13622. Sans cette verification, un motif ajoute - hors du chemin de negation rouvrirait le defaut par la porte de service. - """ - assert not mod.has_live_lift("Le concern n'est pas levé.") - assert not mod.has_live_lift("**ne pas merger tant qu'il n'est pas levé**") - assert not mod.has_live_lift("ce point n'est pas levé") - # positive lointaine : la levee locale reste reconnue - assert mod.has_live_lift("Le concern est levé.") - - -def test_13635_negation_apres_masculin_exclue(): - """Forme avec negation APRES le mot : 'levee non acquise' => 'levé non acquis'.""" - assert not mod.has_live_lift("Le concern est levé non acquis.") - - -def test_13635_conditionnel_feminin_nest_pas_annule_par_le_correctif(): - """Control 2 (#13635) : le correctif n'ouvre PAS de porte conditionnelle - du cote masculin qui serait fermee du cote feminin. - - Verifie par faux negatif (symetrie) : CONDITIONAL_LIFT ne neutralise - aujourd'hui que les formes a la 1re personne (« et je leve / et je merge »), - pas la passive 3e personne « et est leve(e) ». Le feminin - (« et le point est levée ») et le masculin (« et le point est levé ») se - comportent de facon IDENTIQUE apres correctif — le correctif n'introduit - aucune asymetrie de genre. - """ - # le correctif rend le masculin symetrique du feminin (aucun des deux n'est - # neutralise par CONDITIONAL_LIFT, mais rien n'est introduit non plus) - assert mod.has_live_lift("corrige la ligne 19 et le point est levé.") == \ - mod.has_live_lift("corrige la ligne 19 et le point est levée.") - - -def test_13635_conditionnel_je_leve_masculin_reste_bloquant(): - """Les formes conditionnelles a la 1re personne restent bloquantes apres - correctif (regression CONDITIONAL_LIFT deja cablée, que le correctif ne - touche pas) — miroir de test_lift_conditionnel_nest_pas_une_levee (#11201).""" - assert mod.classify( - "myia-ai-01", - "Une seule chose a changer — corrige la ligne 19 et je leve le concern." - ) == "BOT-CONCERN" + assert [v["sha"] for v in res["voided_lifts"]] == ["2d6e4c3642"] + + +# --- #13635 : LIFT_MARKERS ne connaissait que le feminin. Les formes +# MASCULINES de la levee passive (« est levé », « sont levés ») manquaient, alors +# que ce depot nomme ce qui se leve au masculin (le concern / le point / le nit). +# Un motif se valide par ses faux negatifs, pas par ses hits : chaque colonne du +# tableau de l'issue est un test. + +def test_13635_masculin_leve_reconnu(): + """Les 6 formes du tableau de l'issue rendent True apres correctif.""" + # feminines (deja couvertes — controle de symetrie) + assert mod.has_live_lift("**Tes trois reserves sont levees.**") or mod.has_live_lift("**Tes trois réserves sont levées.**") + assert mod.has_live_lift("**La reserve est levee.**") or mod.has_live_lift("**La réserve est levée.**") + # masculines (le correctif) + assert mod.has_live_lift("**[ai-01]** Tes trois concerns sont levés.") + assert mod.has_live_lift("**[ai-01]** Le concern est levé.") + assert mod.has_live_lift("**[ai-01]** Levée de la réserve NanoClaw.") or mod.has_live_lift("**[ai-01]** Leve de la reserve NanoClaw.") + assert mod.has_live_lift("**[ai-01]** Le point est levé.") + + +def test_13635_masculin_singulier_leve(): + assert mod.has_live_lift("Le nit est levé.") + assert mod.has_live_lift("Le concern est levé.") + + +def test_13635_negation_masculin_restaure_pas_levee(): + """Control 3 (#13635) : le nouveau motif masculin passe par `_lift_is_negated`. + + Une negation directe (« n'est pas levé ») doit reste exclue, comme c'est le + cas pour le feminin depuis #13622. Sans cette verification, un motif ajoute + hors du chemin de negation rouvrirait le defaut par la porte de service. + """ + assert not mod.has_live_lift("Le concern n'est pas levé.") + assert not mod.has_live_lift("**ne pas merger tant qu'il n'est pas levé**") + assert not mod.has_live_lift("ce point n'est pas levé") + # positive lointaine : la levee locale reste reconnue + assert mod.has_live_lift("Le concern est levé.") + + +def test_13635_negation_apres_masculin_exclue(): + """Forme avec negation APRES le mot : 'levee non acquise' => 'levé non acquis'.""" + assert not mod.has_live_lift("Le concern est levé non acquis.") + + +def test_13635_conditionnel_feminin_nest_pas_annule_par_le_correctif(): + """Control 2 (#13635) : le correctif n'ouvre PAS de porte conditionnelle + du cote masculin qui serait fermee du cote feminin. + + Verifie par faux negatif (symetrie) : CONDITIONAL_LIFT ne neutralise + aujourd'hui que les formes a la 1re personne (« et je leve / et je merge »), + pas la passive 3e personne « et est leve(e) ». Le feminin + (« et le point est levée ») et le masculin (« et le point est levé ») se + comportent de facon IDENTIQUE apres correctif — le correctif n'introduit + aucune asymetrie de genre. + """ + # le correctif rend le masculin symetrique du feminin (aucun des deux n'est + # neutralise par CONDITIONAL_LIFT, mais rien n'est introduit non plus) + assert mod.has_live_lift("corrige la ligne 19 et le point est levé.") == \ + mod.has_live_lift("corrige la ligne 19 et le point est levée.") + + +def test_13635_conditionnel_je_leve_masculin_reste_bloquant(): + """Les formes conditionnelles a la 1re personne restent bloquantes apres + correctif (regression CONDITIONAL_LIFT deja cablée, que le correctif ne + touche pas) — miroir de test_lift_conditionnel_nest_pas_une_levee (#11201).""" + assert mod.classify( + "myia-ai-01", + "Une seule chose a changer — corrige la ligne 19 et je leve le concern." + ) == "BOT-CONCERN" + + +# #13636 (REPAIR #13703) — 4 controles d'integration : le tag `Grain: lane X` +# du dernier commit FONCTIONNEL identifie l'operateur fonctionnel, et un +# override `[OVERRIDE] lane X` pose par un lifter dans LIFT_OVERRIDE_LOGINS +# (= `myia-ai-01`) sur une PR dont la lane est aussi cote ai-01 constitue +# un self-override par construction (REJETE). Avant ce fix, le garde ne +# pouvait rien conclure (le login commit = `jsboige`, l'operateur reel = +# `myia-ai-01` -- l'identite de poussee partagee #13316 masquait le vrai +# operateur). Le fix lit la lane du tag `Grain:` du dernier commit +# fonctionnel (non-merge). + + +def lift_myia_override(body="[OVERRIDE] lane myia-ai-01:CoursIA"): + """Override pose par myia-ai-01 (arbitre tiers).""" + return {"author": {"login": "myia-ai-01"}, "createdAt": at(12), + "body": body + "\n\nTes trois concerns sont leves, levee acquise."} + + +def lift_ai01_comment(body=None): + """Commentaire leveur par myia-ai-01, sans override ni LIFT_MARKER.""" + return {"author": {"login": "myia-ai-01"}, "createdAt": at(12), + "body": body or "Acknowledge."} + + +def commit_with_grain(grain_lane="myia-ai-01:CoursIA"): + """Commit fonctionnel portant le tag Grain: lane X.""" + return {"oid": NIT_OID, "committedDate": at(19), + "messageHeadline": "fix(guard,#13636): test", + "messageBody": f"Grain: MED/guard - lane {grain_lane} - prev: MED/guard #13698\n\nTest commit."} + + +def merge_commit(): + """Commit merge (a sauter dans le parsing).""" + return {"oid": "a" * 40, "committedDate": at(20), + "messageHeadline": "Merge branch 'main' into fix/13636", + "messageBody": ""} + + +def test_13636_self_override_ai01_on_ai01_pr_bloque(): + """#13636 / REPAIR #13703 — controle 1 : override par myia-ai-01 sur PR + dont le Grain lane est `myia-ai-01:` (self-override) doit BLOQUER le + merge (le lifter ne peut pas s'auto-arbiter). Avant ce fix, le gate + passait vert (le `or` annulait le garde, et l'operateur reel etait + masque par l'identite de poussee partagee `jsboige`). Avec le fix, le + tag Grain identifie l'operateur fonctionnel et le self-override est + detecte puis rejete. + """ + bot_nit = {"author": {"login": "clusterManager-Myia"}, + "createdAt": at(9), + "body": "**[NanoClaw]** CHANGES_REQUESTED - 2 concerns structurels."} + res = run([bot_nit, lift_myia_override()], + commits=[merge_commit(), commit_with_grain("myia-ai-01:CoursIA")], + pr_author="jsboige") + assert res["blocked"] is True, "self-override par ai-01 sur PR ai-01 doit BLOQUER" + + +def test_13636_override_ai01_on_po2024_pr_passe(): + """#13636 / REPAIR #13703 — controle 2 : override par myia-ai-01 sur PR + dont le Grain lane est `myia-po-2024:` (autre lane) doit PASSER le + merge (l'arbitre tiers est distinct de l'operateur fonctionnel). + """ + bot_nit = {"author": {"login": "clusterManager-Myia"}, + "createdAt": at(9), + "body": "**[NanoClaw]** CHANGES_REQUESTED - 2 concerns structurels."} + res = run([bot_nit, lift_myia_override()], + commits=[commit_with_grain("myia-po-2024:CoursIA-2")], + pr_author="jsboige") + assert res["blocked"] is False, "override tiers par ai-01 sur PR po-2024 doit PASSER" + + +def test_13636_pr_sans_grain_fail_open(): + """#13636 / REPAIR #13703 — controle 3 : PR sans tag Grain sur le dernier + commit fonctionnel. On ne peut pas conclure (pas de signal), on laisse + passer (fail-OPEN) — un override bien forme par un tiers reste valide. + """ + bot_nit = {"author": {"login": "clusterManager-Myia"}, + "createdAt": at(9), + "body": "**[NanoClaw]** CHANGES_REQUESTED - 2 concerns structurels."} + no_grain_commit = {"oid": NIT_OID, "committedDate": at(19), + "messageHeadline": "fix: typo", + "messageBody": "No grain tag here"} + res = run([bot_nit, lift_myia_override()], + commits=[merge_commit(), no_grain_commit], + pr_author="jsboige") + assert res["blocked"] is False, "PR sans Grain : fail-OPEN sur override" + + +def test_13636_payload_reel_pr_13703_bloque(): + """#13636 / REPAIR #13703 — controle 4 : payload REEL de PR #13703 avant + merge (`authors` liste, login affiche `jsboige`, tag Grain + `myia-ai-01:CoursIA`). Avant ce fix, le gate passait vert (parser + defect : `authors` rend liste, mon code lisait `author` objet et rendait + `last_committer_login=""`, donc `pr_operator=pr_author="jsboige"` ; le + `or` annulait le garde et `lifter != pr_operator` (VRAI) ouvrait la + porte sans consulter `_has_named_override`). Avec ce fix, le tag Grain + identifie `myia-ai-01:CoursIA`, l'override est reconnu comme self-override, + le gate BLOQUE. + """ + bot_nit = {"author": {"login": "clusterManager-Myia"}, + "createdAt": at(9), + "body": "**[NanoClaw]** CHANGES_REQUESTED - 2 concerns structurels."} + pr_13703_payload = { + "oid": "6c59372c02ac908bb95f54e56428e78fc49e2092", + "committedDate": "2026-08-30T18:20:34Z", + "authors": [{"login": "jsboige"}, {"login": "claude"}], + "messageHeadline": "fix(guard,#13636): trappe d'override ai-01 sur sa propre PR", + "messageBody": "Grain: MED/guard - lane myia-ai-01:CoursIA - prev: MED/guard #13698\n\n#13636 signale qu'un commentaire [OVERRIDE] lane myia-ai-01:CoursIA pose par myia-ai-01 sur sa PROPRE PR levait la branche BLOCK...", + } + res = run([bot_nit, lift_myia_override()], + commits=[pr_13703_payload], + pr_author="jsboige") + assert res["blocked"] is True, "PR #13703 payload REEL : self-override doit BLOQUER"