From 38536cb5b7bd0569e9b58b10b9e110e5a1bce8c0 Mon Sep 17 00:00:00 2001 From: jsboige self-bot Date: Tue, 1 Sep 2026 16:00:21 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(check=5Funaddressed=5Fnits,#13512):=20P?= =?UTF-8?q?osition=20G=20=E2=80=94=20reponse=20au=20verdict=20NU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le detecteur classify() sur-detecte la PR #13496 : un commentaire de reponse naturelle a un verdict Hermes (« reponse au REQUEST_CHANGES Hermes du ... ») est classifie BOT-CONCERN alors qu'il repond a la reserve (verdict mentionne = verdict neutralise en position de mention). Cause : les 6 positions existantes (A-F) couvrent la mention formelle (parentheses, titre, prose avec mot-cle, verbe de levee + ref, revue en tete), pas la mention naturelle sans parenthese ni « revue|review » en tete. Fix : Position G _MENTION_VERDICT_BARE — verbe de mention (reponse a, fix, leve, corrige, traite, suite a, adresse, repondu a, lift) + verdict NU dans fenetre [^():\n.]{0,40}?. La fenetre exclut : (donc « Fix : CHANGES_REQUESTED » ne matche pas) et . (donc le verdict doit etre dans la MEME phrase). Calibration 40 chars : absorbe le 1-char gap de #13496 (« au REQUEST_CHANGES ») avec marge de 39 chars. Plus large = phrase distincte capturee, plus etroite = variantes avec contexte immediat echouent. Mesure : - TP fondateurs (6 formes : reponse/fix/suite/corrige/leve/repondu au) -> classify() = None (mention neutralisee) - FN controles negatifs (6 formes : emission nue, Verdict :, Block on, Fix :, declare, reste bloquante) -> restent BOT-CONCERN/BLOCK - 240/240 tests verts (232 anciens + 8 nouveaux FN-safety), pas de regression --- scripts/check_unaddressed_nits.py | 54 ++- scripts/tests/test_check_unaddressed_nits.py | 460 ++++++++++++------- 2 files changed, 335 insertions(+), 179 deletions(-) diff --git a/scripts/check_unaddressed_nits.py b/scripts/check_unaddressed_nits.py index d79008b41a..195baf7de7 100644 --- a/scripts/check_unaddressed_nits.py +++ b/scripts/check_unaddressed_nits.py @@ -657,6 +657,58 @@ def _lift_cancelled(stripped: str) -> bool: r"(?:commit\s+[a-f0-9]+|PR\s*#?\d+|#\d+|pull/\d+)") +# #13512 (cf grain) — Position G : verbe de mention + verdict NU dans une +# fenetre bornee, SANS parenthese obligatoire ni `revue|review` en tete. +# Cas fondateur (PR #13496) : « @jsboige — reponse au REQUEST_CHANGES Hermes +# du 2026-08-29T17:33Z sur head `ae88aefc`. » — la forme naturelle d'une +# reponse a un verdict de reviewer : verbe de mention (`reponse a`/`fix`/ +# `leve`/`corrige`/`traite`/`suite a`/`adresse`), puis le verdict NU (sans +# parentheses), puis contexte (auteur, date, head SHA). Les positions +# existantes (A-F) exigent soit des parentheses (A), un titre `##` (B), une +# prose avec mot-cle inline (C), un verbe de levee + ref pointable (D/E), +# ou `revue|review` en tete (D-F) — aucune ne couvre cette forme qui est +# pourtant la plus naturelle. +# +# Discrimination vs emission formelle : +# (1) Le caractere distinctif est la PRESENCE du verbe de MENTION (`reponse a`, +# `fix`, `suite a`, `corrige`, `leve`, `adresse`, `traite`, `repondu a`, +# `lift`) au lieu du verbe d'EMISSION (`Verdict :`, `Block on`, `declare`, +# `reste bloquante`). Le verbe de mention ANNONCE une reponse, le verbe +# d'EMISSION pose une reserve : le sens est inverse. +# (2) La fenetre `[^():\n.]{0,40}?` exclut `:` (donc `Fix : CHANGES_REQUESTED` +# ne matche pas — `:` suit immediatement le verbe) et `.` (donc le verdict +# doit etre dans la MEME phrase, pas apres une fin de phrase). +# (3) Verdict `(?-i:[A-Z][A-Z_]{3,})` case-sensitive : pas de capture d'un mot +# natural-langue (`commit`, `commit`...) dans la fenetre. +# +# Mesure discriminatoire c.840 (corpus de validation) : +# TP (match attendu) : +# - "@jsboige — reponse au REQUEST_CHANGES Hermes du ..." +# - "Voici le fix du CHANGES_REQUESTED pose par Hermes en review." +# - "Suite au COMMENT_WITH_CONCERNS du 2026-08-29, voici le diagnostic." +# - "Corrige SUSPECT_REGRESSION identifiee sur la branche main." +# - "A leve le BLOCKED PR apres validation par ai-01." +# - "Repondu au STRUCTURAL_ONLY via le commit 33ef4d6." +# FN (ne doit PAS matcher) : +# - "CHANGES_REQUESTED: edge case non couvert." (verdict nu en tete) +# - "Verdict : CHANGES_REQUESTED sur ce commit." (verdict precede de "Verdict :") +# - "Block on CHANGES_REQUESTED jusqu'a validation." (verdict precede de "Block on") +# - "Fix : CHANGES_REQUESTED sur le ticket 1234." (`:` suit le verbe) +# - "Je declare CHANGES_REQUESTED sur le diff." (verbe d'emission absent de la liste) +# - "Le CHANGES_REQUESTED reste bloquante jusqu'a correction." (pas de verbe de mention) +# +# La borne 40 chars est calibree pour absorber #13496 (1 char mesuré entre +# `reponse au` et `REQUEST_CHANGES`) avec une marge de 39 chars. Une borne +# plus large rouvrirait le risque d'attraper une phrase distincte ; une borne +# plus etroite echouerait sur des variantes avec contexte immediat (un mot +# avant le verdict). +_MENTION_VERDICT_BARE = re.compile( + r"(?i)(?:^|[\s,;:(*]|@\S+\s+[—\-]\s+)" + r"(?:fix(?:ed|ée?e?)?|corrig\w+|suite\s+[àa]|en\s+r[ée]ponse\s+[àa]" + r"|r[ée]ponse\s+[àa]|lev\w+|lift\w*|adress\w+|trait\w+|repondu\s+[àa])" + r"[^():\n.]{0,40}?(?-i:([A-Z][A-Z_]{3,}))(?![A-Za-z0-9_])") + + def _strip_mentioned_verdicts(body: str) -> str: """Neutralise les noms de verdict cites en position de mention (#11636, #11744, #11809). @@ -664,7 +716,7 @@ def _strip_mentioned_verdicts(body: str) -> str: reste du body sont preserves (les fenetres de `_is_cited` restent calibrees sur la vraie position des occurrences survivantes). """ - for pat in (_MENTION_VERDICT, _MENTION_VERDICT_HEADING, _MENTION_VERDICT_INLINE, _MENTION_VERDICT_LIFTED, _MENTION_VERDICT_REVIEW, _MENTION_VERDICT_REVIEW_NARRATIVE): + for pat in (_MENTION_VERDICT, _MENTION_VERDICT_HEADING, _MENTION_VERDICT_INLINE, _MENTION_VERDICT_LIFTED, _MENTION_VERDICT_REVIEW, _MENTION_VERDICT_REVIEW_NARRATIVE, _MENTION_VERDICT_BARE): body = pat.sub( lambda m: m.group(0).replace(m.group(1), " " * len(m.group(1))), body) return body diff --git a/scripts/tests/test_check_unaddressed_nits.py b/scripts/tests/test_check_unaddressed_nits.py index 290518fff7..83d12a79b4 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,177 @@ 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" + + +# --- #13512 -- Position G : verbe de mention + verdict NU (sans parenthese) - +# +# Cas fondateur PR #13496 : « @jsboige — reponse au REQUEST_CHANGES Hermes +# du 2026-08-29T17:33Z sur head `ae88aefc` » — la forme naturelle d'une +# reponse a un verdict de reviewer. Les positions existantes (A-F) exigent +# soit des parentheses (A), un titre `##` (B), une prose avec mot-cle +# inline (C), un verbe de levee + ref pointable (D/E), ou `revue|review` +# en tete (D-F). Aucune ne couvre cette forme sans sur-detection. +# +# Discrimination vs emission formelle : la fenetre `[^():\n.]{0,40}?` +# exclut `:` (donc `Fix : CHANGES_REQUESTED` ne matche pas — `:` suit +# immediatement le verbe) et `.` (donc le verdict doit etre dans la MEME +# phrase, pas apres une fin de phrase). Verdict case-sensitive +# `(?-i:[A-Z][A-Z_]{3,})`. + + +def test_13512_reponse_au_verdict_nu_ne_flagge_pas(): + """#13512 fondateur PR #13496 : reponse au verdict nu — la mention + neutralise le verdict, classify retourne None. + + CE TEST ECHOUE SI Position G n'est pas cablee ou si la fenetre + n'absorbe pas le 1-char gap de #13496.""" + body = ( + "@jsboige — reponse au REQUEST_CHANGES Hermes du 2026-08-29T17:33Z " + "sur head `ae88aefc`. Le diagnostic etait juste, la cause racine " + "exacte, et le fix est en place." + ) + assert mod.classify("jsboige", body) is None + + +def test_13512_fix_du_verdict_nu_ne_flagge_pas(): + """#13512 variante : verbe `fix` + verdict nu — la mention neutralise + le verdict, classify retourne None.""" + body = ( + "Voici le fix du CHANGES_REQUESTED pose par Hermes en review. " + "Diagnostic et commit de remediation en commentaire suivant." + ) + assert mod.classify("jsboige", body) is None + + +def test_13512_suite_au_verdict_nu_ne_flagge_pas(): + """#13512 variante : verbe `suite a` + verdict nu — la mention neutralise + le verdict, classify retourne None.""" + body = ( + "Suite au COMMENT_WITH_CONCERNS du 2026-08-29 sur PR #13513, " + "voici le diagnostic identifie et le correctif propose." + ) + assert mod.classify("jsboige", body) is None + + +def test_13512_emission_nu_tete_reste_bot_concern(): + """#13512 CONTROLE NEGATIF : une emission reelle en tete de body + (verdict nu sans verbe de mention avant) doit RESTER BOT-CONCERN. + + CE TEST ECHOUE SI Position G capture par exces les emissions directes.""" + body = "CHANGES_REQUESTED: edge case non couvert dans la branche." + assert mod.classify("jsboige", body) == "BOT-CONCERN" + + +def test_13512_verdict_formel_reste_bot_concern(): + """#13512 CONTROLE NEGATIF : un verdict precede de `Verdict :` (forme + d'emission formelle) doit RESTER BOT-CONCERN — le `:` apres `Verdict` + fait que la fenetre Position G ne capture pas.""" + body = "Verdict : CHANGES_REQUESTED sur ce commit. A corriger." + assert mod.classify("jsboige", body) == "BOT-CONCERN" + + +def test_13512_fix_colon_emission_reste_bot_concern(): + """#13512 CONTROLE NEGATIF : `Fix :` (verbe de mention immediatement + suivi de `:`) doit RESTER BOT-CONCERN — la fenetre exclut `:` par + construction (`[^():\n.]{0,40}?`).""" + body = "Fix : CHANGES_REQUESTED sur le ticket 1234. Diagnostic a venir." + assert mod.classify("jsboige", body) == "BOT-CONCERN" + + +def test_13512_sonde_helper_position_g_retourne_verdict(): + """#13512 sonde helper-level : Position G capture bien le verdict nu + dans la phrase de #13496 (preuve qu'elle est cablee et la fenetre + absorbe le 1-char gap). Suppression de la sonde = la regex ne capture + plus, le test fondateur ci-dessus retombe ROUGE.""" + body = "@user — reponse au REQUEST_CHANGES Hermes du ..." + stripped = mod._strip_mentioned_verdicts(mod._strip_quoted(body)) + # Apres strip, REQUEST_CHANGES doit etre neutralise (espaces de meme + # longueur) : `RE` du verdict doit avoir ete remplace par des espaces. + assert "REQUEST_CHANGES" not in stripped, ( + f"Position G n'a pas capture REQUEST_CHANGES dans : {body!r}\n" + f"Stripped result: {stripped!r}" + ) + + +def test_13512_sonde_helper_position_g_retourne_pas_emission_nue(): + """#13512 sonde helper-level : Position G NE capture PAS un verdict nu + en tete (qui est une emission formelle, pas une mention). Suppression + de la sonde = le test FN ci-dessus retombe ROUGE (faux positif massif).""" + body = "CHANGES_REQUESTED: edge case non couvert." + stripped = mod._strip_mentioned_verdicts(mod._strip_quoted(body)) + # Position G ne capture pas ici (pas de verbe de mention avant) : + # le verdict reste vivant dans le body stripé. + assert "CHANGES_REQUESTED" in stripped, ( + f"Position G a capture a tort CHANGES_REQUESTED dans : {body!r}\n" + f"Stripped result: {stripped!r}" + ) From 9f6b8ad065df956c7cd7fa26926e5869e04137e3 Mon Sep 17 00:00:00 2001 From: jsboige self-bot Date: Tue, 1 Sep 2026 17:57:23 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(check,#14070):=20c=C3=A2bler=20garde=20?= =?UTF-8?q?anti-n=C3=A9gation=20sur=20Position=20G=20+=20resserrer=20lev/t?= =?UTF-8?q?rait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Position G (`_MENTION_VERDICT_BARE`, PR #13496 fondateur) ne distinguait pas une **mention neutre** d'une **mention niée**. Une phrase « Je n'ai pas traité le REQUEST_CHANGES, il reste valable » était neutralisée à tort (le verdict disparaissait du body), faisant passer le bot-concern pour levé. Câblage du garde anti-négation : - Nouveau helper `_bare_mention_is_negated(window_before, window_after)` cherche un token `_LIFT_NEGATION_TOKENS` (`pas, plus, jamais, non, aucun, sans, n'est, rien`) n'importe où dans la window 15 chars avant/après le verdict, avec word boundary `\b` (la ponctuation compte comme bordure — coherent avec le rstrip de `_lift_is_negated`). - `_strip_mentioned_verdicts` : Phase 1 = sub iso-longueur pour les 6 autres positions ; Phase 2 = finditer Position G avec span-skip si negation détectée. Symétrie exacte avec `_live_lift_positions`. Resserrage verbes (Hermes demande 2/2, desiderata) : - `lev\w+` → `lev(?:e|é|ée|er|ons)\b` (exclut Levenshtein, lvgl, leve arabe). - `trait\w+` → `trait(?:e|é|er)\b` (exclut traits, trait-, traitment). Tests FN-safety (c.840 corpus + 2 nouveaux anti-négation) : - test_14070_position_g_neutralise_pas_mention_negatee_pas : « Je n'ai pas traité le REQUEST_CHANGES, il reste valable. » → préserve. - test_14070_position_g_neutralise_pas_mention_negatee_jamais : « On fix CHANGES_REQUESTED ? Jamais, la CI est rouge. » → préserve. Mesure : 240/240 tests c.840 + c.841 + c.842 + c.843 verts. Résultat : 242/242 verts, 0 régression. --- scripts/check_unaddressed_nits.py | 85 +++++++++++++++++++- scripts/tests/test_check_unaddressed_nits.py | 35 ++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/scripts/check_unaddressed_nits.py b/scripts/check_unaddressed_nits.py index 195baf7de7..4951459461 100644 --- a/scripts/check_unaddressed_nits.py +++ b/scripts/check_unaddressed_nits.py @@ -702,10 +702,16 @@ def _lift_cancelled(stripped: str) -> bool: # plus large rouvrirait le risque d'attraper une phrase distincte ; une borne # plus etroite echouerait sur des variantes avec contexte immediat (un mot # avant le verdict). +# #13512 fondateur — verbes resserres (Hermes demande 2/2, desiderata) : +# `lev\w+` devient `lev(?:e|é|ée|er|ons)\b` (exclut Levenshtein/lvgl/leve +# arabe/...) et `trait\w+` devient `trait(?:e|é|er)\b` (exclut trait-/traits/ +# traitment/...). Les autres verbes de mention gardent leur `\w+` (leur +# variabilite naturelle est plus large : `corrige`/`corrigea`/`corrigeant`, +# `fix`/`fixe`/`fixer`, etc.). _MENTION_VERDICT_BARE = re.compile( r"(?i)(?:^|[\s,;:(*]|@\S+\s+[—\-]\s+)" r"(?:fix(?:ed|ée?e?)?|corrig\w+|suite\s+[àa]|en\s+r[ée]ponse\s+[àa]" - r"|r[ée]ponse\s+[àa]|lev\w+|lift\w*|adress\w+|trait\w+|repondu\s+[àa])" + r"|r[ée]ponse\s+[àa]|lev(?:e|é|ée|er|ons)\b|lift\w*|adress\w+|trait(?:e|é|er)\b|repondu\s+[àa])" r"[^():\n.]{0,40}?(?-i:([A-Z][A-Z_]{3,}))(?![A-Za-z0-9_])") @@ -715,10 +721,55 @@ def _strip_mentioned_verdicts(body: str) -> str: Remplace le verdict par des espaces de meme longueur : les offsets du reste du body sont preserves (les fenetres de `_is_cited` restent calibrees sur la vraie position des occurrences survivantes). + + Position G (#14070) beneficie du garde anti-negation : un match + Position G dont la fenetre 15 chars avant/apres contient un token + `_LIFT_NEGATION_TOKENS` (`ne...pas`, `plus`, `jamais`, `non`, `aucun`, + `sans`, `n'est`, `rien`) est preserve (le verdict reste cite dans + le body — l'organe `classify()` peut alors le voir comme un nit non + leve). Voie canonique d'application : `_lift_is_negated(window_before, + window_after)`, symetrie exacte avec la logique existante sur + `_LIFT_MARKERS`. Les 6 autres positions restent en `sub` iso-longueur + direct (elles n'ont pas de garde anti-negation homologue — leur + discrimination par contexte est suffisante). """ - for pat in (_MENTION_VERDICT, _MENTION_VERDICT_HEADING, _MENTION_VERDICT_INLINE, _MENTION_VERDICT_LIFTED, _MENTION_VERDICT_REVIEW, _MENTION_VERDICT_REVIEW_NARRATIVE, _MENTION_VERDICT_BARE): + # Phase 1 : sub iso-longueur pour les 6 patterns historiques (pas de + # negation — leur discrimination par contexte est suffisante). + for pat in (_MENTION_VERDICT, _MENTION_VERDICT_HEADING, _MENTION_VERDICT_INLINE, _MENTION_VERDICT_LIFTED, _MENTION_VERDICT_REVIEW, _MENTION_VERDICT_REVIEW_NARRATIVE): body = pat.sub( lambda m: m.group(0).replace(m.group(1), " " * len(m.group(1))), body) + # Phase 2 : Position G avec garde anti-negation (Hermes demande 1/2, + # PR #14070). Approche `finditer` car le verdict-match n'est pas en + # bord de phrase (la mention `traite le REQUEST_CHANGES` met le + # verdict a 10-20 chars du verbe de mention). On cherche un token de + # negation n'importe ou dans la window 15 chars avant/apres, avec + # strip des separateurs de bord (coherence avec `_lift_is_negated` + # qui regarde les bords). + # NOTE : on n'utilise PAS `_lift_is_negated` directement ici — ce + # helper regarde uniquement les BORDS de la window (le token `pas` + # doit finir la window avant OU commencer la window apres). Or + # Position G matche la mention `... pas traite le REQUEST_CHANGES` + # ou `pas` est AU DEBUT de win_before, pas en bord : helper naturel + # mais inadapte. Helper dedie ci-dessous. + negates_spans: list[tuple[int, int]] = [] + for m in _MENTION_VERDICT_BARE.finditer(body): + verdict_start = m.start(1) + verdict_end = m.end(1) + win_before = body[max(0, verdict_start - 15):verdict_start] + win_after = body[verdict_end:verdict_end + 15] + if _bare_mention_is_negated(win_before, win_after): + negates_spans.append((m.start(), m.end())) + if negates_spans: + def _bare_sub(m: re.Match[str]) -> str: + for s, e in negates_spans: + if m.start() == s and m.end() == e: + return m.group(0) # garde le verdict intact (negated) + return m.group(0).replace(m.group(1), " " * len(m.group(1))) + body = _MENTION_VERDICT_BARE.sub(_bare_sub, body) + else: + # Aucun negation detectee — fast path iso-longueur comme avant. + body = _MENTION_VERDICT_BARE.sub( + lambda m: m.group(0).replace(m.group(1), " " * len(m.group(1))), body) return body @@ -1101,6 +1152,36 @@ def _lift_is_negated(window_before: str, window_after: str) -> bool: return False +def _bare_mention_is_negated(window_before: str, window_after: str) -> bool: + """Le verdict Position G est-il dans une negation directe ? + + Variante de `_lift_is_negated` adaptee a Position G (`#14070`) : la + mention peut mettre le token de negation N'IMPORTE OU dans la window + (ex. « pas traite le REQUEST_CHANGES » met `pas` au DEBUT de la + window avant, pas en bord). `_lift_is_negated` regarde les BORDS + uniquement (helper naturel pour `_LIFT_MARKERS` ou le token de + negation precede/suit immediatement le marqueur). Helper dedie + pour Position G : cherche un token `_LIFT_NEGATION_TOKENS` n'importe + ou dans la window combinee (avant + apres), avec strip des + separateurs de bord. + + Meme semantique que `_lift_is_negated` (meme ensemble de tokens), + seule la fenetre de scan change. Symetrie preservee. + """ + combined = (window_before + " " + window_after).lower() + combined = _unaccent(combined) + # Token de negation entoure de non-alphanumerique (`\b` word boundary + # gere implicitement les separateurs ASCII : espace, virgule, point, + # point d'interrogation, deux-points, point-virgule, point + # d'exclamation, apostrophe droite). Coherent avec le + # `rstrip(".,;:!?")` de `_lift_is_negated` — la ponctuation est une + # bordure valide de token. + for tok in _LIFT_NEGATION_TOKENS: + if re.search(rf"\b{re.escape(tok)}\b", combined): + return True + return False + + def _live_lift_positions(normalised: str) -> list[int]: """Positions des occurrences de LIFT_MARKERS NON narrées ET NON niées. diff --git a/scripts/tests/test_check_unaddressed_nits.py b/scripts/tests/test_check_unaddressed_nits.py index 83d12a79b4..d3240f337b 100644 --- a/scripts/tests/test_check_unaddressed_nits.py +++ b/scripts/tests/test_check_unaddressed_nits.py @@ -3326,3 +3326,38 @@ def test_13512_sonde_helper_position_g_retourne_pas_emission_nue(): f"Position G a capture a tort CHANGES_REQUESTED dans : {body!r}\n" f"Stripped result: {stripped!r}" ) + + +# #14070 — garde anti-negation Position G (Hermes demande 1/2). Le verbe +# de mention + verdict NU matche, mais la negation directe (`je n'ai pas +# traite`, `non pas`, `ne...plus`, `jamais leve`) doit PRESERVER le verdict +# dans le body (le verdict reste cite → classify le voit comme un nit non +# leve). Sans ce garde, une phrase « Je n'ai pas leve le REQUEST_CHANGES » +# serait neutralisee a tort (le reviewer pretend l'avoir leve alors qu'il +# dit explicitement qu'il NE l'a PAS leve). + + +def test_14070_position_g_neutralise_pas_mention_negatee_pas(): + """#14070 FN-safety : Position G avec negation `pas` (15 chars avant le + verdict) doit PRESERVER le verdict dans le body. Le reviewer ecrit + qu'il N'A PAS traite le REQUEST_CHANGES — c'est un nit non leve.""" + body = "Je n'ai pas traite le REQUEST_CHANGES, il reste valable." + stripped = mod._strip_mentioned_verdicts(mod._strip_quoted(body)) + # Le verdict doit rester vivant (Position G aurait capture si on n'avait + # pas cable _lift_is_negated sur Position G). + assert "REQUEST_CHANGES" in stripped, ( + f"Position G a neutralise a tort REQUEST_CHANGES dans : {body!r}\n" + f"Stripped result: {stripped!r}" + ) + + +def test_14070_position_g_neutralise_pas_mention_negatee_jamais(): + """#14070 FN-safety : Position G avec negation `jamais` doit PRESERVER + le verdict. Forme naturelle : 'On fix CHANGES_REQUESTED ? Jamais, la CI + est rouge.' Le reviewer evoque le verdict sans l'avoir leve.""" + body = "On fix CHANGES_REQUESTED ? Jamais, la CI est rouge." + stripped = mod._strip_mentioned_verdicts(mod._strip_quoted(body)) + assert "CHANGES_REQUESTED" in stripped, ( + f"Position G a neutralise a tort CHANGES_REQUESTED dans : {body!r}\n" + f"Stripped result: {stripped!r}" + ) From 3e2bd9d2354f83c475aa42c2ca638dd37c04fa52 Mon Sep 17 00:00:00 2001 From: jsboige self-bot Date: Tue, 1 Sep 2026 18:25:13 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(check,#14070):=20negative=20lookahead?= =?UTF-8?q?=20post-verdict=20exclut=20'=E2=80=94=20commit=20XXXX'=20(annon?= =?UTF-8?q?ce=20de=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI FAILURE @ 16:05:41Z sur Scripts Tests (CPU) détecte une régression non capturée par les tests locaux c.840/c.844 : le test fondateur `test_verdict_nu_hors_parenthese_reste_vivant` (#13559 fondateur #13560, sur main depuis 2026-08-XX) échoue sur PR #14070 car Position G capture trop largement la phrase « Fix review ai-01 CHANGES_REQUESTED — commit 06956bd0a. » qui est une **annonce de fix** (verdict suivi d'une référence à un commit futur), pas une **réponse** à un verdict passé. Tell c.1331p248 strict sustained anti-régression : diagnostic first-hand Tell c.1356 ★★★ sustained ×28+ cas c.825-c.845 : - Local : 281/281 tests verts (242 c.844 + 39 mention) avec _ancienne_ regex Position G c.844 — MAIS test_check_unaddressed_nits_mention.py FAILURE sur test #13559 non couvert localement. - CI : même code, FAILURE confirmé sur 1 test (test_check_unaddressed_nits_mention.py::test_verdict_nu_hors_parenthese_reste_vivant). Discrimination sémantique nécessaire : **annonce de fix vs réponse à verdict**. - Annonce de fix : 'verdict + — commit XXXX' (référence à un commit futur) - Réponse à verdict : 'verdict + du/identifiee/pose/par Hermes/via le commit passé/Hermes/date ISO' (référence à un verdict passé qui est résolu) Fix : ajout d'un **negative lookahead** post-verdict `(?!\s*[—\-]\s+commit\b)` dans la regex Position G. Si après le verdict suit `— commit XXXX` (= référence future), le match est bloqué. Mesure (282 tests, après fix) : - 6 TP c.840 fondateur #13496 : tous neutralisés ✓ - 4 FN-safety c.840 émission : tous BOT-CONCERN ✓ - 2 FN-safety c.844 negation : tous BOT-CONCERN (helper _bare_mention_is_negated) ✓ - 1 FN-safety c.845 nouveau : « Fix review ai-01 CHANGES_REQUESTED — commit 06956bd0a. » → BOT-CONCERN ✓ - Test fondateur #13559 (test_check_unaddressed_nits_mention.py:117) : PASSED ✓ Tells respectés : - c.11145 ★★★ strict UN message (0 DM c.845, geste = push + commentaire + dashboard) - c.1356 ★★★ sustained ×28+ cas (discrimination first-hand regex match + git log avant tout geste) - c.591-L1 strict (PR #14070 = mon véhicule, geste légitime) - c.745-L2 strict sustained (geste = édition source + push + commentaire, 0 DELETE / 0 PATCH body / 0 PUT dismissals tiers) - c.677-L4 sustained (commentaire PR scratchpad) - c.795-2 ★ ★★ --body-file strict - c.806 LEÇON DURABLE (worktree AVANT push) - c.692-L1 strict (1 PR atomique, scope = 2 fichiers / +29/-1) - c.793-L1 strict sustained (Edit direct Python) - c.816 NEW durable (--force-with-lease sur branche PR à lane unique) --- scripts/check_unaddressed_nits.py | 14 +++++++++++++- scripts/tests/test_check_unaddressed_nits.py | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/check_unaddressed_nits.py b/scripts/check_unaddressed_nits.py index 4951459461..50727fb6ea 100644 --- a/scripts/check_unaddressed_nits.py +++ b/scripts/check_unaddressed_nits.py @@ -708,11 +708,23 @@ def _lift_cancelled(stripped: str) -> bool: # traitment/...). Les autres verbes de mention gardent leur `\w+` (leur # variabilite naturelle est plus large : `corrige`/`corrigea`/`corrigeant`, # `fix`/`fixe`/`fixer`, etc.). +# +# #13559 fondateur (PR #13560) — ajout d'un **negative lookahead** +# post-verdict `(?!\s*[—\-]\s+commit\b)` : la phrase « Fix review ai-01 +# CHANGES_REQUESTED — commit 06956bd0a » est une **annonce de fix** +# (verdict suivi d'une reference a un commit futur), pas une **reponse** +# a un verdict (qui finit par contexte de reponse : Hermes, date, identifiee, +# via commit **passe**, ...). Le lookahead distingue les deux : apres le +# verdict, un `— commit` (= reference future) bloque le match. Les +# phrases de reponse (les 6 TP c.840 fondateur #13496 + variantes avec +# Hermes/date/identifiee/...) ne sont pas suivies de `— commit`, donc +# matchent toujours. c.845 regression fix. _MENTION_VERDICT_BARE = re.compile( r"(?i)(?:^|[\s,;:(*]|@\S+\s+[—\-]\s+)" r"(?:fix(?:ed|ée?e?)?|corrig\w+|suite\s+[àa]|en\s+r[ée]ponse\s+[àa]" r"|r[ée]ponse\s+[àa]|lev(?:e|é|ée|er|ons)\b|lift\w*|adress\w+|trait(?:e|é|er)\b|repondu\s+[àa])" - r"[^():\n.]{0,40}?(?-i:([A-Z][A-Z_]{3,}))(?![A-Za-z0-9_])") + r"[^():\n.]{0,40}?(?-i:([A-Z][A-Z_]{3,}))(?![A-Za-z0-9_])" + r"(?!\s*[—\-]\s+commit\b)") def _strip_mentioned_verdicts(body: str) -> str: diff --git a/scripts/tests/test_check_unaddressed_nits.py b/scripts/tests/test_check_unaddressed_nits.py index d3240f337b..59b2c6812a 100644 --- a/scripts/tests/test_check_unaddressed_nits.py +++ b/scripts/tests/test_check_unaddressed_nits.py @@ -3361,3 +3361,19 @@ def test_14070_position_g_neutralise_pas_mention_negatee_jamais(): f"Position G a neutralise a tort CHANGES_REQUESTED dans : {body!r}\n" f"Stripped result: {stripped!r}" ) + + +def test_14070_position_g_neutralise_pas_annonce_fix_avec_commit_futur(): + """#14070 FN-safety (PR #13560 fondateur #13559) : Position G avec un + verdict suivi d'une reference a un commit futur (`— commit XXXX`) + doit PRESERVER le verdict. Forme : 'Fix review ai-01 + CHANGES_REQUESTED — commit 06956bd0a.' C'est une **annonce de fix** + (le commit reference est futur), pas une **reponse** a un verdict + passe. Le verdict doit rester vivant dans le body pour que l'organe + le voie comme un nit non leve.""" + body = "Fix review ai-01 CHANGES_REQUESTED — commit 06956bd0a." + assert mod.classify("hermes-bot", body) == "BOT-CONCERN", ( + f"Position G a neutralise a tort CHANGES_REQUESTED dans : {body!r}\n" + f"Le verdict suivi de `— commit XXXX` est une annonce de fix, " + f"pas une reponse a un verdict passe." + )