diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index de79b5f683..34e1f5c3a2 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -118,6 +118,19 @@ une PR que le merge-gate refusera. Voir `red_backlog` et `unaddressed_review_points`. +Ardoise de lane : la mesure qui rend un faux "rien livre" impossible (L721) +-------------------------------------------------------------------------- +Mesure du 2026-09-12 : une lane a envoye une escalation URGENT claimant +"x22 cycles, rien livre par ma lane, pool tari structurellement" alors +qu'elle avait cree 8 PRs DEEP de genre CONTENU dans les 48 h precedentes, +la plus recente 10 h avant l'alerte (337 issues ouvertes a cet instant). +La lecon L721 (proactive-coordination.md) dit deja d'interroger le TAG de +lane, jamais `--author` ; ce qui manquait n'etait pas une regle de plus +mais la mesure rendue au moment ou la decision se prend. Le picker affiche +donc l'ardoise de la lane appelante (PRs mergees 24 h / 7 j, par tier et +par classe CONTENU/META) sur les deux chemins, reparation comme tirage. +Elle est INFORMATIONNELLE : aucun gate, aucun changement du tirage. + Usage ----- python scripts/pick_idle_grain.py --lane myia-po-2026:CoursIA @@ -2434,6 +2447,236 @@ def print_drought_banner(d: dict, restricted: int, fell_back: bool) -> None: print() +# --- L721 : ardoise de lane, la mesure rendue AU MOMENT de la decision -------- +# +# Mesure du 2026-09-12. La lane myia-po-2023:CoursIA-2 a envoye une +# escalation URGENT claimant "x22 cycles, rien livre par ma lane, pool +# global tari structurellement" -- alors que cette meme lane avait CREE +# 8 PRs DEEP de genre CONTENU dans les 48 h precedentes (#15662, #15607, +# #15595, #15582, #15542, #15540, #15537, #15519), la plus recente 10 h +# avant l'alerte, et que 337 issues etaient ouvertes a cet instant. +# +# La lecon L721 (proactive-coordination.md, "stale-tracker guard") dit +# deja de compter par le TAG de lane, jamais par `--author` (le compte de +# poussee `jsboige` est partage par toutes les lanes -- 50 PRs ouvertes +# sur 55 sous ce login, mesure du 2026-08-22). La regle etait correcte ; +# rien ne la faisait mordre. Ajouter une regle de plus serait l'echec- +# pendule : ce qui manquait est un ORGANE qui rend la mesure visible au +# moment ou la decision se prend. Le picker est le premier geste de +# chaque cycle (regle 5 de proactive-coordination) : c'est lui qui porte +# l'ardoise. +# +# Elle est INFORMATIONNELLE : elle ne change pas le tirage, ne refuse +# rien, n'ajoute aucune condition bloquante. Un garde qui refuserait sur +# une ardoise vide reproduirait l'incident des "lanes 2" (un garde qui +# drainait les lanes actives) -- et une ardoise vide PEUT etre vraie, au +#quel cas c'est un fait a escalader, pas un motif de blocage. + +LANE_RECORD_WINDOW_HOURS = 24 +LANE_RECORD_WINDOW_DAYS = 7 +# ~100 merges/jour sur la flotte => ~700 attendues sur 7 j ; 1000 laisse +# la marge, et le franchissement est SURVEILLE (champ `truncated` du +# record), pas muet -- convention POOL_FETCH_LIMIT. +LANE_RECORD_FETCH_LIMIT = 1000 +LANE_RECORD_TIERS = ("DEEP", "MED", "LIGHT") + + +def fetch_lane_record_prs( + *, + cache: PayloadCache | None = None, + cache_mode: str = "off", + cache_status: dict[str, dict[str, Any]] | None = None, +) -> tuple[list[dict], str | None]: + """Corpus brut de l'ardoise : les PRs mergees des 7 derniers jours. + + Rend ``(prs, erreur)``. En cas d'echec, liste vide ET erreur nommee : + l'ardoise sera rendue NON MESUREE, jamais un zero d'absence de mesure + (un zero silencieux fabriquerait exactement le faux "rien livre" que + cet organe existe pour refuter). + + Le filtre de date est SERVEUR (``--search merged:>=...``) : la lecon + de ``fetch_visits`` (mesure du 2026-08-23 -- 44 % de la population de + la fenetre perdue par un tri par date de creation coupe a N puis filtre + cote client) vaut ici mot pour mot. Une ardoise sous-comptee + CONSENTIRAIT le faux constat d'idle au lieu de le refuter. + """ + cutoff = NOW - dt.timedelta(days=LANE_RECORD_WINDOW_DAYS) + stamp = cutoff.strftime("%Y-%m-%dT%H:%M:%S+00:00") + command = [ + "gh", "pr", "list", "--repo", REPO, "--state", "merged", + "--limit", str(LANE_RECORD_FETCH_LIMIT), + "--search", f"merged:>={stamp}", + "--json", "number,body,mergedAt", + ] + identity = [ + "gh", "pr", "list", "--repo", REPO, "--state", "merged", + "--limit", str(LANE_RECORD_FETCH_LIMIT), + "--window-days", str(LANE_RECORD_WINDOW_DAYS), + "--json", "number,body,mergedAt", + ] + + def fetch_raw() -> list[dict]: + out = subprocess.run( + command, + capture_output=True, text=True, encoding="utf-8", errors="replace", + check=True, timeout=60, + ).stdout + return json.loads(out) + + try: + prs = _cached_payload( + "lane_record", identity, fetch_raw, + cache=cache, cache_mode=cache_mode, + ttl_seconds=VISITS_CACHE_TTL_SECONDS, + cache_status=cache_status, + ) + except (subprocess.CalledProcessError, json.JSONDecodeError, + subprocess.TimeoutExpired, OSError) as exc: + return [], f"{type(exc).__name__}: {exc}" + + cache_entry = (cache_status or {}).get("lane_record") or {} + if cache_entry.get("status") == "stale": + # Le payload date du dernier refresh reussi : le bacquet 24 h est + # potentiellement ampute des merges les plus recents. Le dire + # plutot que de rendre une mesure retiree pour une mesure fraiche. + return prs, ("cache stale apres echec du refresh: " + str( + cache_entry.get("error") or "erreur inconnue")) + return prs, None + + +def _lane_record_bucket() -> dict: + return {"total": 0, + "by_tier": {tier: 0 for tier in LANE_RECORD_TIERS}, + "contenu": 0, "meta": 0, "hors_enumeration": 0, + "prs": []} + + +def lane_delivery_record(lane: str, prs: list[dict] | None, *, + now: dt.datetime | None = None, + error: str | None = None, + truncated: bool = False) -> dict: + """Ardoise de `lane` : ses PRs MERGEES par fenetre, tier et classe. + + Fenetres : 24 h et 7 j. Decompte par tier DECLARE (DEEP/MED/LIGHT ; un + tier hors enumeration garde sa propre cle plutot que d'etre jete -- un + compte qui somme a moins que le total sans le dire est un sous-compte) + et par classe de genre (CONTENU / META, l'enumeration fermee de + variation-protocol reprise telle quelle des constantes du module ; un + genre non resolu compte `hors_enumeration`, fail-CLOSED et NOMME -- + meme politique que `substance_drought`). + + L'attribution suit le TAG DE LANE -- via `parse_grain_tag`, + l'extracteur PARTAGE avec variation-tag-guard.yml (formes tolerees : + `Grain:`, `## Grain` + ligne suivante, `**Grain** :`, casse + indifferentes), jamais `--author` : l'auteur GitHub ne porte aucune + information de lane sur ce depot. Une PR sans tag lisible n'est + comptee nulle part (deviner sa lane serait pire -- meme arithmetique + que `red_backlog` et `unattributed_blocked_prs`). + """ + now = NOW if now is None else now + cutoffs = { + "24h": now - dt.timedelta(hours=LANE_RECORD_WINDOW_HOURS), + "7d": now - dt.timedelta(days=LANE_RECORD_WINDOW_DAYS), + } + buckets = {"24h": _lane_record_bucket(), "7d": _lane_record_bucket()} + undated = 0 + for pr in prs or []: + tag = parse_grain_tag(pr.get("body") or "") + if not tag or tag.get("lane") != lane: + continue + raw_merged = pr.get("mergedAt") or "" + try: + merged = dt.datetime.fromisoformat(raw_merged.replace("Z", "+00:00")) + except ValueError: + undated += 1 + continue + tier = tag.get("tier") or "?" + genre = canonicalize_genre(tag.get("genre") or "") + if genre in CONTENU: + klass = "contenu" + elif genre in META: + klass = "meta" + else: + klass = "hors_enumeration" + item = {"number": pr.get("number"), "tier": tier, + "genre": tag.get("genre"), "mergedAt": raw_merged} + for key, cutoff in cutoffs.items(): + if merged >= cutoff: + bucket = buckets[key] + bucket["total"] += 1 + bucket["by_tier"][tier] = bucket["by_tier"].get(tier, 0) + 1 + bucket[klass] += 1 + bucket["prs"].append(item) + return { + "lane": lane, + "measured": error is None, + "error": error, + "truncated": bool(truncated), + "windows": buckets, + "undated": undated, + } + + +def print_lane_record(record: dict | None) -> None: + """L'ardoise en texte -- le rappel L721 qui rend un faux "rien livre" + impossible a ecrire honnetement. + + Rien sur un record absent (modes sans lane) : pas de paragraphe + parasite. Le cas NON MESURE parle en MAJUSCULES comme les autres + fail-open du picker ("NON MESUREE" lisible au survol) et donne la + commande de verification manuelle -- un record illisible est une + question, pas une mesure de zero. + """ + if not record: + return + lane = record.get("lane") + if not record.get("measured"): + cutoff = (NOW - dt.timedelta(days=LANE_RECORD_WINDOW_DAYS)).strftime("%Y-%m-%d") + print(f"ARDOISE DE LA LANE NON MESUREE ({record.get('error') or 'erreur inconnue'}) :") + print("ce tirage ne dit RIEN des livraisons recentes de la lane -- un zero") + print("d'ardoise ne serait pas une mesure (L721). Verifier a la main AVANT") + print("d'escalader un constat d'idle :") + print(f" gh pr list --state merged --search 'merged:>={cutoff}' " + f"--json number,body,mergedAt") + print() + return + print(f"Ardoise de la lane {lane} -- PRs MERGEES dont le body porte " + f"`lane {lane}` (L721) :") + for key, label in (("24h", "24 h"), ("7d", "7 j")): + b = record["windows"][key] + tiers = " / ".join(f"{t} {b['by_tier'].get(t, 0)}" + for t in LANE_RECORD_TIERS) + extra = "" + if b["hors_enumeration"]: + extra = f" | hors-enumeration {b['hors_enumeration']}" + print(f" {label:>4} : {b['total']} merge(s) | {tiers} " + f"| CONTENU {b['contenu']} / META {b['meta']}{extra}") + if record.get("undated"): + print(f" ({record['undated']} PR(s) sans mergedAt lisible, non comptees)") + if record.get("truncated"): + print(f" ATTENTION : corpus tronque a la limite de fetch " + f"({LANE_RECORD_FETCH_LIMIT}) -- les comptes ci-dessus sont des") + print(" bornes INFERIEURES, pas des totaux.") + w24 = record["windows"]["24h"] + w7 = record["windows"]["7d"] + if w24["total"] == 0 and w7["total"] == 0: + print(" 0 merge sur les deux fenetres. Avant d'ecrire 'rien livre par ma") + print(" lane', verifier que tes PRs portent le tag `Grain: ... lane") + print(f" {lane}` : une PR sans tag lisible n'est comptee nulle part.") + else: + pool = w24["prs"] if w24["prs"] else w7["prs"] + top = sorted(pool, key=lambda p: p.get("mergedAt") or "", + reverse=True)[:3] + listing = ", ".join(f"#{p['number']} {p['tier']}/{p['genre']}" + for p in top) + print(f" derniers merges : {listing}") + print("Avant d'ecrire 'rien livre par ma lane' ou 'pool tari' : confronter") + print("le constat a CETTE mesure. L'attribution se lit sur le tag `Grain:`,") + print("jamais sur --author (le compte de poussee jsboige est partage par") + print("toutes les lanes).") + print() + + # --- #14591 Volet A : persistance CSV de --prev-genre entre cycles ---------------- # # Le picker penalise le genre precedent via --prev-genre (G-VAR-3, variation- @@ -2735,6 +2978,23 @@ def main(argv: list[str] | None = None) -> int: "a reporter sur l'issue.") return 1 + # L721 : ardoise de la lane, calculee AVANT le garde rouge pour que les + # DEUX chemins (reparation comme tirage) la portent -- c'est au moment ou + # la lane consulte l'outil que la mesure doit etre sous ses yeux. + # INFORMATIONNELLE : aucun gate, aucun changement du tirage (cf section + # L721 en tete de fichier). + lane_record = None + if args.lane: + record_prs, record_err = fetch_lane_record_prs( + cache=payload_cache, + cache_mode=effective_cache_mode, + cache_status=cache_status, + ) + lane_record = lane_delivery_record( + args.lane, record_prs, error=record_err, + truncated=record_err is None + and len(record_prs) >= LANE_RECORD_FETCH_LIMIT) + # Garde "reparer son rouge d'abord" : AVANT le tirage, sinon le grain neuf # est deja sous les yeux quand le refus arrive, et c'est lui qui gagne. backlog = red_backlog(args.lane, args.red_hours, args.red_count, @@ -2749,10 +3009,14 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps({"lane": args.lane, "mode": "repair", "assignment": "reparer-son-rouge", "grain": (backlog.get("red") or [None])[0], - "red_hours": args.red_hours, **backlog}, + "red_hours": args.red_hours, + "lane_record": lane_record, **backlog}, ensure_ascii=False, indent=2)) else: print_red_assignment(args.lane, backlog, args.red_hours) + # Apres l'assignation : l'en-tete "GRAIN DU CYCLE" doit rester la + # premiere ligne lue (test pinné), l'ardoise vient en rappel. + print_lane_record(lane_record) return 0 if not args.json: print_nits_gap(backlog) @@ -2960,6 +3224,7 @@ def main(argv: list[str] | None = None) -> int: "recent_delivery": {str(k): v for k, v in delivery.items()}, "red_backlog": backlog, "substance_drought": drought, + "lane_record": lane_record, "cache": cache_status, "filters": { "active": filter_active, @@ -3050,6 +3315,7 @@ def main(argv: list[str] | None = None) -> int: "en a aucun, d'en declarer un : une zone chaude sans EPIC " "n'a personne de comptable pour la contrepartie.") print() + print_lane_record(lane_record) print_delivery(delivery_sig, args.delivery_boost_max) print(f"Pool ouvert : {len(pool)} issues.") print(f"Candidats apres admission/filtres : " diff --git a/scripts/tests/test_pick_idle_grain.py b/scripts/tests/test_pick_idle_grain.py index 59a559b2e6..cfbb185090 100644 --- a/scripts/tests/test_pick_idle_grain.py +++ b/scripts/tests/test_pick_idle_grain.py @@ -322,6 +322,10 @@ def _patch_backlog(monkeypatch, prs, states, nits=None): # le reseau interroger des numeros de PR fictifs (mesure : 2,9 s pour trois # numeros), et la suite deviendrait non deterministe sans jamais rougir. monkeypatch.setattr(pig, "unaddressed_review_points", lambda nums: dict(nits or {})) + # L721 : l'ardoise de lane ajoute un fetch gh dans main() AVANT le garde + # rouge -- meme neutralisation par defaut, meme raison (reseau + + # determinisme). Les tests qui veulent une ardoise la re-patchent apres. + monkeypatch.setattr(pig, "fetch_lane_record_prs", lambda **k: ([], None)) def _pr(n, lane, age_hours, *, draft=False): diff --git a/scripts/tests/test_pick_lane_record.py b/scripts/tests/test_pick_lane_record.py new file mode 100644 index 0000000000..61d7a30ce5 --- /dev/null +++ b/scripts/tests/test_pick_lane_record.py @@ -0,0 +1,370 @@ +"""Tests de l'ardoise de lane du picker (L721). + +Fondation (mesure du 2026-09-12) : la lane myia-po-2023:CoursIA-2 a envoye +une escalation URGENT claimant « x22 cycles, rien livre par ma lane, pool +tari structurellement » alors qu'elle avait cree 8 PRs DEEP de genre +CONTENU dans les 48 h precedentes (#15662, #15607, #15595, #15582, #15542, +#15540, #15537, #15519), la plus recente 10 h avant l'alerte, 337 issues +etant ouvertes. La regle L721 etait correcte, aucun organe ne la faisait +mordre : l'ardoise rend la mesure visible au moment ou la decision se +prend -- dans la sortie du picker, premier geste de chaque cycle. + +Un detecteur se valide par ses faux negatifs ET ses sur-accusations. La +sur-accusation ici serait double : compter les PRs d'une AUTRE lane sous +l'identite de poussee partagee (le defaut structurel que L721 nomme), ou +transformer l'ardoise en garde -- elle est INFORMATIONNELLE, un gate sur +une ardoise vide reproduirait l'incident des « lanes 2 » (un garde qui +drainait les lanes actives). Les tests ci-dessous sont ordonnes par +gravite du degat si ils cassent. +""" + +import datetime as dt +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import pick_idle_grain as pig # noqa: E402 + + +LANE = "myia-po-2023:CoursIA-2" # la lane de l'incident fondateur +OTHER_LANE = "myia-po-2026:CoursIA" # une autre lane, meme poussee +NOW_FIXED = dt.datetime(2026, 9, 12, 12, 0, tzinfo=dt.timezone.utc) + + +def _merged_at(hours_ago: float) -> str: + return (NOW_FIXED - dt.timedelta(hours=hours_ago)).strftime( + "%Y-%m-%dT%H:%M:%SZ") + + +def _pr(number, lane, tier, genre, hours_ago, *, author="jsboige", + body=None): + """PR mergee telle que fetch_lane_record_prs la rend (author present + pour pinner qu'il n'est JAMAIS lu).""" + return {"number": number, + "body": body if body is not None + else f"Grain: {tier}/{genre} -- lane {lane}\n", + "mergedAt": _merged_at(hours_ago), + "author": {"login": author}} + + +# Les 8 PRs de l'incident : 5 mergees dans les 24 h, 3 entre 24 et 48 h. +# Genres tous CONTENU, tiers tous DEEP -- le profil exact de la livraison +# que l'escalation claimait inexistante. +FOUNDING = [ + _pr(15662, LANE, "DEEP", "genai", 4), + _pr(15607, LANE, "DEEP", "lean", 8), + _pr(15595, LANE, "DEEP", "notebook-python", 13), + _pr(15582, LANE, "DEEP", "qc", 18), + _pr(15542, LANE, "DEEP", "training", 22), + _pr(15540, LANE, "DEEP", "slides", 30), + _pr(15537, LANE, "DEEP", "research-code", 38), + _pr(15519, LANE, "DEEP", "notebook-lean", 45), +] +FOUNDING_NUMBERS = {15662, 15607, 15595, 15582, 15542, 15540, 15537, 15519} + + +# --- CONTROLE POSITIF : le replay exact de l'incident ---------------------- + +def test_founding_case_replay_renders_the_eight_content_prs(): + """Le replay du 2026-09-12 : la lane qui claimait « rien livre » avait + 8 DEEP CONTENU mergees en 48 h. L'ardoise doit rendre 5 CONTENU sur + 24 h et 8 sur 7 j, en NOMMANT les numeros -- un constat sans preuve + nommee se discute, un constat qui cite #15662 se verifie.""" + rec = pig.lane_delivery_record(LANE, FOUNDING, now=NOW_FIXED) + assert rec["measured"] is True + w24, w7 = rec["windows"]["24h"], rec["windows"]["7d"] + assert w24["contenu"] == 5 and w24["total"] == 5 + assert w24["by_tier"]["DEEP"] == 5 + assert w7["contenu"] == 8 and w7["total"] == 8 + assert w7["meta"] == 0 + assert {p["number"] for p in w7["prs"]} == FOUNDING_NUMBERS + + +def test_negative_control_no_content_renders_zero_contenu(): + """Controle NEGATIF du precedent : meme forme, genres tous META -> + contenu == 0 et meta > 0. Sans ce temoin, un compteur « toujours + non-zero » passerait le test fondateur en comptant n'importe quoi.""" + prs = [_pr(15662, LANE, "LIGHT", "guard", 4), + _pr(15607, LANE, "MED", "tooling", 8), + _pr(15595, LANE, "LIGHT", "docs", 13)] + rec = pig.lane_delivery_record(LANE, prs, now=NOW_FIXED) + w24, w7 = rec["windows"]["24h"], rec["windows"]["7d"] + assert w24["contenu"] == 0 and w7["contenu"] == 0 + assert w24["meta"] == 3 and w7["meta"] == 3 + assert w24["by_tier"] == {"DEEP": 0, "MED": 1, "LIGHT": 2} + + +# --- ATTRIBUTION : le tag de lane, jamais l'identite de poussee ----------- + +def test_attribution_follows_the_lane_tag_never_the_push_identity(): + """L721, le coeur : toutes les PRs sont poussees sous le compte partage + `jsboige` ; seules celles dont le TAG dit cette lane comptent. Compter + par --author attribuerait a la lane les livraisons de toute la flotte + -- ou l'inverse, et c'est l'inverse qui a produit le faux « rien + livre » : la lane ne se reconnaissait pas dans `gh pr list --author`.""" + prs = [ + _pr(1, LANE, "DEEP", "lean", 5), + _pr(2, OTHER_LANE, "DEEP", "lean", 5), # autre lane, meme poussee + _pr(3, "myia-po-2024:CoursIA-2", "MED", "genai", 5), + ] + rec = pig.lane_delivery_record(LANE, prs, now=NOW_FIXED) + assert rec["windows"]["7d"]["total"] == 1 + assert [p["number"] for p in rec["windows"]["7d"]["prs"]] == [1] + # Et la reciproque : la lane voisine voit ses PRs, pas les notres. + other = pig.lane_delivery_record(OTHER_LANE, prs, now=NOW_FIXED) + assert other["windows"]["7d"]["total"] == 1 + assert other["windows"]["7d"]["prs"][0]["number"] == 2 + + +def test_untagged_pr_is_never_attributed(): + """Une PR sans tag lisible n'est comptee NULLE part -- deviner sa lane + serait pire (meme arithmetique que red_backlog / unattributed_blocked). + Le defaut a attraper : la compter partout « par defaut », ce qui + fabriquerait des ardoises identiques pour toutes les lanes.""" + prs = [_pr(1, LANE, "DEEP", "lean", 5), + _pr(2, None, "DEEP", "lean", 5, body="pas de tag\n")] + rec = pig.lane_delivery_record(LANE, prs, now=NOW_FIXED) + assert rec["windows"]["7d"]["total"] == 1 + other = pig.lane_delivery_record(OTHER_LANE, prs, now=NOW_FIXED) + assert other["windows"]["7d"]["total"] == 0 + + +def test_loose_tag_forms_are_parsed_like_the_guard(): + """Requirement de parite : l'ardoise herite de l'extracteur PARTAGE + (grain_tag.parse_grain_tag, celui de variation-tag-guard.yml), pas + d'un regex plus strict. Les formes documentees -- gras, titre `## + Grain` + ligne suivante, casse inferieure -- doivent compter ; un + lecteur plus strict sous-compterait des livraisons reelles et + retiendrait le faux « rien livre » par la bande.""" + prs = [ + _pr(1, LANE, "DEEP", "lean", 5, + body="**Grain** : DEEP/lean -- lane %s\n" % LANE), + _pr(2, LANE, "DEEP", "lean", 5, + body="## Grain\n\nDEEP/lean - lane %s\n" % LANE), + _pr(3, LANE, "deep", "Lean", 5, + body="grain: deep/Lean -- lane %s\n" % LANE), + ] + rec = pig.lane_delivery_record(LANE, prs, now=NOW_FIXED) + assert rec["windows"]["24h"]["total"] == 3 + # L'extracteur normalise casse et tier : deep/Lean reste DEEP/lean. + assert rec["windows"]["24h"]["by_tier"]["DEEP"] == 3 + + +# --- FENETRES ET HONNETETE DES COMPTES ------------------------------------ + +def test_window_bucketing_30h_counts_in_7d_only(): + """Une merge a 30 h est dans la fenetre 7 j, pas dans la 24 h -- les + deux fenetres repondent a deux questions differentes ( rythme du jour + vs trajectoire de la semaine) et doivent rester distinguees.""" + rec = pig.lane_delivery_record(LANE, [_pr(1, LANE, "DEEP", "lean", 30)], + now=NOW_FIXED) + assert rec["windows"]["24h"]["total"] == 0 + assert rec["windows"]["7d"]["total"] == 1 + + +def test_offlist_tier_and_genre_are_kept_not_silently_dropped(): + """Un tier hors enumeration garde sa propre cle et un genre non resolu + compte hors_enumeration -- un compte qui somme a moins que le total + sans le dire est un sous-compte, et un sous-compte ici dirait « moins + de contenu » a une lane qui en a livre (fail-CLOSED mais NOMME, meme + politique que substance_drought).""" + rec = pig.lane_delivery_record( + LANE, [_pr(1, LANE, "ULTRA", "diagnostic", 5)], now=NOW_FIXED) + b = rec["windows"]["24h"] + assert b["total"] == 1 + assert b["by_tier"].get("ULTRA") == 1 + assert b["by_tier"]["DEEP"] == 0 and b["by_tier"]["LIGHT"] == 0 + assert b["hors_enumeration"] == 1 + assert b["contenu"] == 0 and b["meta"] == 0 + + +def test_alias_genres_canonicalize_to_contenu(): + """Un alias (translation -> docs, notebook-genai-python -> + notebook-python) se canonicalise AVANT classification : compter les + alias bruts classerait du CONTENU reel en META et gonflerait le + diagnostic de secheresse d'une lane qui livre (mesure 2026-08-31 : la + canonicalisation resout 8 des 11 genres hors-enumeration du corpus).""" + prs = [_pr(1, LANE, "DEEP", "notebook-genai-python", 5), + _pr(2, LANE, "MED", "translation", 5)] + rec = pig.lane_delivery_record(LANE, prs, now=NOW_FIXED) + b = rec["windows"]["24h"] + assert b["contenu"] == 1 and b["meta"] == 1 + + +# --- LECTURE RATEE : jamais un zero d'absence de mesure -------------------- + +def test_unreadable_record_announces_never_renders_zero(capsys): + """Requirement fail-OPEN explicite : une ardoise illisible (gh down, + 403, reseau) doit se DIRE, pas rendre un zero -- un zero silencieux + fabriquerait exactement le faux « rien livre » que l'organe existe + pour refuter. Le print porte le rappel L721 et la commande manuelle.""" + rec = pig.lane_delivery_record(LANE, [], now=NOW_FIXED, + error="CalledProcessError: gh exit 1") + assert rec["measured"] is False + assert rec["error"] == "CalledProcessError: gh exit 1" + assert rec["windows"]["24h"]["total"] == 0 # mais NON MESURE le dit + pig.print_lane_record(rec) + out = capsys.readouterr().out + assert "NON MESUREE" in out + assert "ne serait pas une mesure" in out + assert "gh pr list" in out # la voie de verification manuelle + + +def test_fetch_failure_returns_named_error(monkeypatch): + """Le fetch lui-meme : echec gh -> ([], erreur nommee), pas d'exception + qui tuerait le tirage entier (l'ardoise ne doit jamais empecher de + tirer -- parite avec le fail-open de red_backlog).""" + def boom(cmd, **kwargs): + raise pig.subprocess.TimeoutExpired(cmd, 60) + monkeypatch.setattr(pig.subprocess, "run", boom) + prs, err = pig.fetch_lane_record_prs() + assert prs == [] + assert err and "TimeoutExpired" in err + + +def test_fetch_filters_dates_server_side(monkeypatch): + """La requete DOIT porter --search merged:>= : gh pr list trie par date + de CREATION, et la mesure fondatrice de fetch_visits (2026-08-23) est + de 44 % de population perdue sur une fenetre de 24 h par un filtre + cote client. Une ardoise amputee consentirait le faux constat d'idle + au lieu de le refuter.""" + seen = {} + + class _R: + stdout = "[]" + returncode = 0 + + def fake_run(cmd, **kwargs): + seen["cmd"] = cmd + return _R() + + monkeypatch.setattr(pig.subprocess, "run", fake_run) + prs, err = pig.fetch_lane_record_prs() + assert err is None and prs == [] + cmd = seen["cmd"] + assert any(a.startswith("merged:>=") for a in cmd), cmd + assert "--state" in cmd and "merged" in cmd + assert "--json" in cmd + + +# --- INFORMATIONNELLE : aucun gate, aucune mutation ------------------------ + +def test_record_is_informational_pure_and_ungated(capsys): + """L'ardoise ne gate RIEN : pas de cle de verdict bloquant dans le + record, pas de mutation du corpus (parite avec recent_delivery / + test_candidate_stays_drawable), pas de mot de refus dans le print. + Un gate sur une ardoise vide reproduirait l'incident « lanes 2 ».""" + prs = [dict(p) for p in FOUNDING] + snapshot = [dict(p) for p in prs] + rec = pig.lane_delivery_record(LANE, prs, now=NOW_FIXED) + assert prs == snapshot + assert set(rec) == {"lane", "measured", "error", "truncated", + "windows", "undated"} + pig.print_lane_record(rec) + out = capsys.readouterr().out + assert "REFUS" not in out.upper() + assert "Ardoise de la lane" in out + + +def test_zero_record_print_names_the_tag_hypothesis(capsys): + """Un zero PEUT etre vrai ; le print doit alors orienter vers la cause + la plus frequente d'un faux zero (des PRs sans tag lisible) plutot que + de laisser le zero se lire comme une preuve d'idle.""" + rec = pig.lane_delivery_record(LANE, [], now=NOW_FIXED) + pig.print_lane_record(rec) + out = capsys.readouterr().out + assert "0 merge" in out + assert "sans tag lisible" in out + + +def test_truncated_corpus_surfaced_as_lower_bound(capsys): + """Corpus tronque a la limite de fetch : les comptes restent rendus + mais declares BORNES INFERIEURES -- convention POOL_FETCH_LIMIT, le + plafond se fait franchir en silence par construction sinon.""" + rec = pig.lane_delivery_record(LANE, FOUNDING, now=NOW_FIXED, + truncated=True) + assert rec["truncated"] is True + pig.print_lane_record(rec) + out = capsys.readouterr().out + assert "bornes INFERIEURES" in out + + +# --- INTEGRATION main() : l'ardoise atteint les deux chemins --------------- + +def _red_state(): + """Etat GraphQL d'une PR rouge (check requis en echec), forme + fetch_pr_states -- declencheur `aged` du garde reparer-son-rouge.""" + return { + "number": 1, "mergeable": "MERGEABLE", + "reviews": {"nodes": []}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"contexts": {"nodes": [ + {"name": "PR gate", "conclusion": "FAILURE", "isRequired": True, + "completedAt": "2026-09-12T00:00:00Z"} + ]}}}}]}, + } + + +def _patch_repair(monkeypatch, record_prs): + """main() sur le chemin REPARATION : garde rouge declenche (PR de la + lane, 30 h, rouge requis), organes externes neutralises, ardoise + injectee -- le payload exact du fetch de l'ardoise.""" + created = (pig.NOW - pig.dt.timedelta(hours=30)).strftime("%Y-%m-%dT%H:%M:%SZ") + pr = {"number": 1, "title": "pr 1", + "body": f"Grain: MED/guard -- lane {LANE}\n", + "createdAt": created, "isDraft": False} + monkeypatch.setattr(pig, "fetch_open_prs", lambda: [pr]) + monkeypatch.setattr(pig, "fetch_pr_states", lambda nums: {1: _red_state()}) + monkeypatch.setattr(pig, "unaddressed_review_points", lambda nums: {}) + monkeypatch.setattr(pig, "fetch_lane_record_prs", + lambda **k: (record_prs, None)) + + +def test_main_repair_path_prints_the_record(monkeypatch, capsys): + """Le chemin reparation porte AUSSI l'ardoise : une lane en cycles de + reparation est precisement celle qui peut ecrire « rien livre » -- la + mesure doit etre sous ses yeux sur TOUT chemin de sortie du picker, + apres l'assignation (l'en-tete GRAIN DU CYCLE reste la premiere ligne).""" + _patch_repair(monkeypatch, FOUNDING) + rc = pig.main(["--lane", LANE]) + out = capsys.readouterr().out + assert rc == 0 + assert out.splitlines()[0].startswith("GRAIN DU CYCLE") + assert "Ardoise de la lane" in out + assert "#15662" in out # la PR de l'incident, nommee dans la sortie + + +def test_main_repair_json_carries_the_record(monkeypatch, capsys): + """Les deux canaux disent la meme chose : sans cette cle, la sortie + humaine pourrait porter l'ardoise pendant que --json la tait -- deux + canaux, deux verites (parite avec test_repair_json_carries_a_grain_field).""" + _patch_repair(monkeypatch, []) + rc = pig.main(["--lane", LANE, "--json"]) + payload = json.loads(capsys.readouterr().out) + assert rc == 0 and payload["mode"] == "repair" + assert payload["lane_record"]["measured"] is True + assert payload["lane_record"]["windows"]["7d"]["total"] == 0 + + +def test_main_draw_json_carries_the_record(monkeypatch, capsys): + """Chemin de TIRAGE : le payload --json porte l'ardoise. Le mock + minimal coupe le garde rouge (aucune PR ouverte) et sert un pool vide + -- le tirage rend 0 candidat mais rend TOUTE sa sortie, record inclus. + NOW est fige a NOW_FIXED : les fenetres de l'ardoise se ferment sur + l'horloge REELLE du module, la fixture est relative a NOW_FIXED.""" + monkeypatch.setattr(pig, "NOW", NOW_FIXED) + monkeypatch.setattr(pig, "red_backlog", + lambda *a, **k: {"red": [], "triggers": []}) + monkeypatch.setattr(pig, "fetch_lane_record_prs", + lambda **k: (FOUNDING, None)) + + class _R: + stdout = "[]" + returncode = 0 + + monkeypatch.setattr(pig.subprocess, "run", lambda *a, **k: _R()) + rc = pig.main(["--lane", LANE, "--json", "--no-check-claims"]) + payload = json.loads(capsys.readouterr().out) + assert rc == 0 + assert payload["lane_record"]["windows"]["24h"]["contenu"] == 5