Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions scripts/check_unaddressed_nits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -117,6 +125,17 @@
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 (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 »)
# complete `CHANGES_REQUESTED` (nom) : Hermes self-bot est force a state:COMMENTED
Expand Down Expand Up @@ -714,6 +733,78 @@ 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 <machine:workspace>` /
`[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.

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)
if BLOCAGE_LANE.search(normalised):
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:
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 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)
return False


def ts(value: str | None) -> datetime | None:
if not value:
return None
Expand Down Expand Up @@ -791,6 +882,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 —
Expand Down Expand Up @@ -861,6 +961,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
Expand Down Expand Up @@ -989,6 +1090,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
Expand Down
184 changes: 184 additions & 0 deletions scripts/tests/test_check_unaddressed_nits.py
Original file line number Diff line number Diff line change
Expand Up @@ -1905,3 +1905,187 @@ 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_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
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


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"
Loading