diff --git a/scripts/check_pr_perimeter.py b/scripts/check_pr_perimeter.py index 4a7b3f5a40..8f381283db 100644 --- a/scripts/check_pr_perimeter.py +++ b/scripts/check_pr_perimeter.py @@ -1146,6 +1146,50 @@ def _count_is_provenance_sha(line: str, m: re.Match) -> bool: return bool(_PROVENANCE_SHA_TAIL.match(after)) +# #20086 -- prose descriptive chiffree : un compte OUVERT par un verbe de +# CONSTATATION sur la meme ligne decrit ce que l'auteur a LU ou ATTESTE, pas +# le perimetre de la PR. Founder #20076 (body avant correction) : deux +# phrases -- « `life-lean-stats` (exec 12) atteste 4 fichiers (`Life.lean` +# 253, ...) » (les sources que la cellule lit) et « `phase1-files-list` +# (exec 18) atteste ~80 fichiers `.lean` » (idem) -- confrontees aux 7 +# fichiers reels de la PR, le garde rougissait une PR saine. Le contournement +# applique alors (« sources » / « modules ») a rendu rc=0 : c'est ce qui +# mesure que la classe etait ouverte. +# +# Liste FERMEE -- jamais elargie a du vocabulaire inconnu (cout FN borne et +# visible, meme doctrine que COUNT_WORDS). Le VERBE est ce qui borne +# l'exemption : sans lui la forme fondatrice reste bloquante. +# Review #20125 : `couvre|couvrent|covers?|lists?` retires -- ce sont des +# verbes de PERIMETRE, et « Cette PR couvre N fichiers » est la phrase la plus +# courante d'un body qui annonce son perimetre : l'exemption la blanchissait. +# Le fondateur #20076 n'utilise que « atteste ». +DESCRIPTIVE_VERB = re.compile( + r"\b(?:atteste|attestent|constate|constatent|lit|lisent|" + r"[ée]num[èe]re|[ée]num[èe]rent|r[ée]cense|r[ée]censent|" + r"d[ée]nombre|d[ée]nombrent|" + r"attests?|reads?|counts?|enumerates?)\b", + re.IGNORECASE, +) +# Controle positif exige par l'issue : la forme CANONIQUE de declaration de +# perimetre -- « N fichiers : a, b, c » -- n'est ouverte par aucun verbe et +# reste bloquante. Le deux-points immediat est la garde qui empeche un verbe +# de constatation de blanchir une vraie assertion (« j'atteste 7 fichiers : +# a.py, b.py »), et c'est ce qui separe l'exemption du blanc-seing. +_DESCRIPTIVE_VERB_WINDOW = 80 +_PERIMETER_COLON_TAIL = re.compile(r"^\s*:") + + +def _count_is_descriptive_attestation(line: str, m: re.Match) -> bool: + """#20086: True when the COUNT match `m` is opened by a CONSTATATION verb + within the 80 chars before it, and is not the canonical colon-declared + perimeter form. See DESCRIPTIVE_VERB for the founder measurement.""" + before = line[max(0, m.start() - _DESCRIPTIVE_VERB_WINDOW):m.start()] + if not DESCRIPTIVE_VERB.search(before): + return False + after = PLURAL_PAREN.sub(" ", line[m.end():], count=1) + return not _PERIMETER_COLON_TAIL.match(after) + + def _count_is_exempt(line: str, m: re.Match, ante_context: str = "") -> bool: """True when the specific COUNT match `m` on `line` is exempted by the per-count filters (zero, threshold citation, locative scan scope, @@ -1190,6 +1234,8 @@ def _count_is_exempt(line: str, m: re.Match, ante_context: str = "") -> bool: return True if _count_has_incidental_qualifier(line, m): return True + if _count_is_descriptive_attestation(line, m): + return True # prose descriptive ouverte par un verbe de constatation (#20086) return False diff --git a/scripts/tests/test_check_pr_perimeter.py b/scripts/tests/test_check_pr_perimeter.py index 40b583cb4c..e3c8ef87cb 100644 --- a/scripts/tests/test_check_pr_perimeter.py +++ b/scripts/tests/test_check_pr_perimeter.py @@ -4184,3 +4184,80 @@ def test_both_invoking_workflows_branch_on_rc(): f"{name} ne distingue plus rc=1 : le message de contradiction " "serait rendu pour un echec de mesure (#17273)" ) + + +# --- #20086 : prose descriptive chiffree ouverte par un verbe de constatation + + +def _count_exempt(line: str) -> bool: + m = COUNT_CLAIM.search(line) + assert m is not None, f"aucun COUNT_CLAIM dans la ligne sonde : {line!r}" + return _count_is_exempt(line, m) + + +def test_issue_20086_descriptive_attestation_is_exempt(): + """Founder #20076 (body avant correction) : le compte ouvre sur ce que + l'auteur a LU ou ATTESTE, pas sur le perimetre de la PR.""" + assert _count_exempt( + "- `life-lean-stats` (exec 12) atteste 4 fichiers (`Life.lean` 253, " + "`Life_en.lean` 254, `Hashlife.lean` 700, `Hashlife_en.lean` 701)" + ) + assert _count_exempt("`phase1-files-list` (exec 18) atteste ~80 fichiers `.lean`") + + +def test_issue_20086_canonical_colon_declaration_still_fails(): + """Controle positif exige par l'issue : la forme canonique de declaration + de perimetre -- « N fichiers : a, b, c » -- n'est ouverte par aucun verbe + de constatation et DOIT continuer de rougir.""" + assert not _count_exempt( + "7 fichiers : a.py, b.py, c.py, d.py, e.py, f.py, g.py" + ) + + +def test_issue_20086_colon_guard_survives_a_constatation_verb(): + """Le verbe ne blanchit pas une assertion declaree par deux-points : c'est + la garde qui separe l'exemption du blanc-seing.""" + assert not _count_exempt( + "j'atteste 7 fichiers : a.py, b.py, c.py, d.py, e.py, f.py, g.py" + ) + + +def test_issue_20086_bare_assertion_without_verb_still_fails(): + """Sans verbe de constatation, la phrase reste une revendication.""" + assert not _count_exempt("Le perimetre de cette PR est de 3 fichiers uniquement.") + + +def test_issue_20125_perimeter_verbs_do_not_exempt(): + """Review #20125 : `couvre/couvrent/covers/lists` sont des verbes de + PERIMETRE, pas de constatation -- l'annonce de perimetre la plus courante + doit rester bloquante, sinon l'exemption est un blanc-seing.""" + assert not _count_exempt("Cette PR couvre 4 fichiers.") + assert not _count_exempt("This PR covers 4 files.") + + +def _body_candidates(body: str, n_files: int): + items = [{"kind": "body", "author": "lane", "body": body, "source": "body", "ts": ""}] + return select_candidates(items, n_files)[0] + + +def test_issue_20086_end_to_end_founder_body_is_signal_not_blocking(): + """Bout en bout, au niveau ou #20076 rougissait : la ligne fondatrice + reste EXTRAITE et CONFRONTEE (#12201 : l'exemption ne retire pas le compte + de la selection), mais elle ne BLOQUE plus -- c'est la propriete + `blocking` du candidat qui porte le verdict.""" + body = ( + "- `life-lean-stats` (exec 12) atteste 4 fichiers (`Life.lean` 253, " + "`Life_en.lean` 254, `Hashlife.lean` 700, `Hashlife_en.lean` 701)\n" + "- `phase1-files-list` (exec 18) atteste ~80 fichiers `.lean`\n" + ) + cands = _body_candidates(body, 7) + assert cands, "les lignes doivent rester des candidates (extraction inchangee)" + assert all(not c.blocking for c in cands), [c.text for c in cands if c.blocking] + + +def test_issue_20086_end_to_end_colon_declaration_still_blocks(): + """Controle positif bout en bout : la declaration canonique de perimetre + reste BLOQUANTE sur la meme liste de 7 fichiers.""" + body = "Perimetre : 4 fichiers : Life.lean, Life_en.lean, Hashlife.lean, Hashlife_en.lean\n" + cands = _body_candidates(body, 7) + assert any(c.blocking for c in cands), "la declaration canonique doit bloquer"