diff --git a/scripts/check_unaddressed_nits.py b/scripts/check_unaddressed_nits.py index d8960bcd56..09e36e8a2e 100644 --- a/scripts/check_unaddressed_nits.py +++ b/scripts/check_unaddressed_nits.py @@ -657,16 +657,131 @@ 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). +# #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.). +# +# #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"(?!\s*[—\-]\s+commit\b)") + + def _strip_mentioned_verdicts(body: str) -> str: """Neutralise les noms de verdict cites en position de mention (#11636, #11744, #11809). 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). """ + # 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 @@ -1049,6 +1164,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 2152f4183f..de0d14e85d 100644 --- a/scripts/tests/test_check_unaddressed_nits.py +++ b/scripts/tests/test_check_unaddressed_nits.py @@ -3223,6 +3223,162 @@ def test_13635_conditionnel_je_leve_masculin_reste_bloquant(): "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}" + ) + + +# #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}" + ) + + +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." + ) + + # --------------------------------------------------------------------------- # #13598 - EMISSION informelle d'un LIFT_OVERRIDE_LOGINS # ---------------------------------------------------------------------------