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
4 changes: 2 additions & 2 deletions scripts/ci/fast_lane.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from fast_lane_registry import ( # noqa: E402
PILOT, TRANCHE1, TRANCHE2, TRANCHE3, TRANCHE4, TRANCHE5, TRANCHE6,
TRANCHE7, TRANCHE8, TRANCHE9, TRANCHE10, TRANCHE11, TRANCHE12, TRANCHE13,
TRANCHE14, TRANCHE15, TRANCHE16, Guard,
TRANCHE14, TRANCHE15, TRANCHE16, TRANCHE17, Guard,
)

SHADOW_PREFIX = "fast-lane (ombre): "
Expand Down Expand Up @@ -367,7 +367,7 @@ def main(argv: list[str] | None = None) -> int:
guards = [g for g in PILOT + TRANCHE1 + TRANCHE2 + TRANCHE3 + TRANCHE4
+ TRANCHE5 + TRANCHE6 + TRANCHE7 + TRANCHE8 + TRANCHE9
+ TRANCHE10 + TRANCHE11 + TRANCHE12 + TRANCHE13 + TRANCHE14
+ TRANCHE15 + TRANCHE16
+ TRANCHE15 + TRANCHE16 + TRANCHE17
if not args.only or g.name == args.only]
selected = [g for g in guards if guard_applies(g, changed)]
for guard in guards:
Expand Down
53 changes: 53 additions & 0 deletions scripts/ci/fast_lane_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -1576,3 +1576,56 @@ class Guard:
warn_rc=(2,),
),
]


# ---------------------------------------------------------------------------
# TRANCHE 17 (#19116) -- garde de parite jumeau FR/<lang> scopee au diff.
# Une PR notebook-only qui casse la parite d'un jumeau ``xxx_<lang>.ipynb``
# (re-execution native au lieu du re-rendu T4, #18844) n'etait vue par
# AUCUNE jambe : Scripts Tests (CPU) filtre sur ``scripts/**``,
# ``translation-parity.yml`` ne tourne que sur schedule/dispatch. Le défaut
# atterrissait sur ``main`` vert puis rougissait la PR de scripts suivante.
#
# Temoins fondateurs (rejoues en developpement du garde) :
# - POSITIF : diff complet de #18844 (``origin/main...c4c53386af``) --
# 4 notebooks modifies, 1 paire touchee (medical_chatbot en), rouge
# CODE_DRIFT cellule ``d0d7a23d`` (48 sorties FR vs 41 EN), exit 1 ;
# - NEGATIF : tete de #19115 (``f43225aeef``, re-rendu T4 du meme
# jumeau) -- meme paire touchee, 0 bloquant, exit 0.
#
# Forme : le garde calcule lui-meme son diff (``{base_ref}...HEAD``),
# evalue uniquement les paires dont UN membre change (re-executer seulement
# le FR casse aussi la parite), bloquant. rc=2 = incident d'entree
# (git/JSON), neutre au check-run (forme control-chars-in-cells-guard).
# ---------------------------------------------------------------------------
TRANCHE17: list[Guard] = [
Guard(
name="twin-parity-guard",
source=FAST_LANE_NATIVE,
paths=[
"**/*.ipynb",
"scripts/translation/check_translation_parity.py",
"scripts/translation/check_twin_parity_changed.py",
"scripts/translation/tests/test_check_twin_parity_changed.py",
"scripts/ci/fast_lane.py",
"scripts/ci/fast_lane_registry.py",
],
argv=[
"python", "scripts/translation/check_twin_parity_changed.py",
"--diff", "{base_ref}...HEAD",
],
blocking=True,
# `absorbed=True` (#19118, reserve de revue) : le job always-on lance
# `fast_lane.py --shadow`, et `effective_shadow = args.shadow and not
# guard.absorbed`. Sans absorption, ce garde emettait
# `fast-lane (ombre): twin-parity-guard` avec une conclusion neutre et
# n'entrait pas dans `blocking_failed` : `blocking=True` etait une
# declaration sans effet, et une PR de la forme #18844 (CODE_DRIFT
# d0d7a23d) serait passee. Meme convention que les autres gardes natifs
# absorbes (TRANCHE8/9/10/14) : un garde sans workflow d'origine n'a
# aucun autre emetteur de son nom de check-run.
absorbed=True,
needs_base=True,
warn_rc=(2,),
),
]
20 changes: 16 additions & 4 deletions scripts/tests/test_check_control_chars_in_cells.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from __future__ import annotations

import re
import sys
from pathlib import Path

Expand Down Expand Up @@ -137,8 +138,19 @@ def test_tranche16_enregistree_avec_contrat():

def test_tranche16_cablee_dans_le_moteur():
# Un garde enregistre mais pas somme par le moteur ne tourne jamais :
# parite textuelle import + somme (le defaut que ce test ferme est
# exactement l'oubli de cablage, silencieux par construction).
# import + somme, le defaut ferme est exactement l'oubli de cablage,
# silencieux par construction.
#
# La lecture se fait par TOKEN, pas par adjacence litterale : la parite
# d'origine (« TRANCHE16, Guard, ») rougissait des qu'une tranche
# posterieure s'inserait entre TRANCHE16 et Guard -- or l'objet
# surveille est l'omission de cablage, pas l'ordre des tranches
# (#19118, TRANCHE17).
src = (CI_DIR / "fast_lane.py").read_text(encoding="utf-8")
assert "TRANCHE16, Guard," in src
assert "+ TRANCHE15 + TRANCHE16" in src
imported = re.search(r"from fast_lane_registry import \((.*?)\)", src, re.S)
assert imported, "bloc d'import de fast_lane_registry introuvable"
names = [n.strip() for n in imported.group(1).replace("\n", " ").split(",")]
assert "TRANCHE16" in names, "TRANCHE16 importee mais absente : cablage rompu"
summed = re.search(r"\bguards = \[(.*?)\]", src, re.S)
assert summed, "expression de somme des tranches introuvable"
assert re.search(r"\+\s*TRANCHE16\b", summed.group(1)), "TRANCHE16 non sommee par le moteur"
15 changes: 12 additions & 3 deletions scripts/tests/test_fast_lane.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,9 +656,18 @@ def _drive_mixed_emission(monkeypatch, pilot_rc, tranche_rc):
blocking=True, paths=["**/*.ipynb"], absorbed=True)
monkeypatch.setattr(fl, "PILOT", [pilot])
monkeypatch.setattr(fl, "TRANCHE1", [tranche])
monkeypatch.setattr(fl, "TRANCHE2", [])
monkeypatch.setattr(fl, "TRANCHE4", [])
monkeypatch.setattr(fl, "TRANCHE5", [])
# Toutes les AUTRES tranches doivent etre videes : n'en vider que quelques-unes
# laissait tourner les gardes REELLES des tranches oubliees. Leur rc etait
# injecte par le faux `run_argv` ci-dessous (`pilot_rc` pour tout argv autre
# que `cmd-tranche`), donc absorber une garde de tranche bloquante rendait
# `rc=1` et faisait rougir le job : le test de contraste du pilote mesurait
# alors le registre au lieu de la lane a deux gardes. Mesure du 2026-10-05 :
# l'absorption des tranches 15/16 (#19168) et 17 (#19118) a fait echouer ce
# test, qui passait par accident tant qu'aucune tranche oubliee n'etait
# absorbe et bloquante.
for _tranche in sorted(n for n in vars(fl) if n.startswith("TRANCHE")):
if _tranche != "TRANCHE1":
monkeypatch.setattr(fl, _tranche, [])
monkeypatch.setattr(fl, "changed_files", lambda _ref: ["x.ipynb"])
monkeypatch.setattr(
fl, "run_argv",
Expand Down
206 changes: 206 additions & 0 deletions scripts/translation/check_twin_parity_changed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Garde de parite jumeau FR/<lang> scopee au diff d'une PR (#19116).

Motivation mesuree (#18844, 2026-10-04) : une PR notebook-only qui casse la
parite d'un jumeau ``xxx_<lang>.ipynb`` (re-execution native au lieu du
re-rendu T4) n'est vue par AUCUNE jambe -- ``Scripts Tests (CPU)`` ne se
declenche pas (filtre ``scripts/**``) et ``translation-parity.yml`` ne tourne
que sur ``schedule``/``workflow_dispatch``. Le défaut atterrit sur ``main``
vert, puis rougit la prochaine PR de scripts qui n'y est pour rien.

Ce garde evalue UNIQUEMENT les paires touchees par le diff de la PR : pour
chaque notebook modifie, il considere la paire ``(xxx.ipynb,
xxx_<lang>.ipynb)`` si le notebook est le jumeau, ou toutes les paires
``(xxx.ipynb, xxx_<lang>.ipynb)`` existantes si le notebook est la source FR.
Re-executer seulement le FR casse aussi la parite (l'EN garde ses anciennes
sorties) : la paire est donc evaluee des qu'UN des deux membres change.

Les verdicts bloquants et la mecanique d'invariants sont ceux de
``check_translation_parity.py`` (Epic #10038 grain B), reutilise en
librairie : CODE_DRIFT / STRUCTURE_DRIFT / OUTPUT_FABRICATED bloquent,
FR_CONTAM reste advisory sauf ``--strict-fr``.

Codes de sortie :
0 aucune paire touchee, ou toutes les paires touchees passent ;
1 au moins une anomalie bloquante sur une paire touchee ;
2 incident d'entree (git/JSON illisible) -- neutre au check-run via
``warn_rc=(2,)`` dans le registre fast-lane, jamais un faux vert.

Temoin positif (fondateur, #18844) : tete ``c4c53386af`` -- le jumeau EN
``medical_chatbot_en.ipynb`` re-execute nativement, cellule ``d0d7a23d`` a
41 sorties contre 48 au FR. Temoin negatif : ``#19115`` (tete
``f43225aeef``) -- re-rendu T4 du meme jumeau, la paire passe.
"""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
from pathlib import Path, PurePosixPath
from typing import List, Optional, Set, Tuple

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))

import check_translation_parity as ctp # noqa: E402

BLOCKING_VERDICTS = {"CODE_DRIFT", "STRUCTURE_DRIFT", "OUTPUT_FABRICATED"}


def changed_notebooks(repo_root: Path, diff_range: str) -> Optional[List[str]]:
"""Notebooks (chemins POSIX relatifs) modifies par ``diff_range``.

Rend ``None`` sur incident git (l'appelant sort en 2) : un diff illisible
n'est pas une absence de défaut.
"""
proc = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=AMR", diff_range],
cwd=repo_root, capture_output=True, text=True, encoding="utf-8",
errors="replace",
)
if proc.returncode != 0:
print(f"ERROR: git diff illisible ({diff_range}) : {proc.stderr.strip()}",
file=sys.stderr)
return None
return [line.strip() for line in proc.stdout.splitlines()
if line.strip().endswith(".ipynb")]


def derive_candidate_pairs(changed: List[str]) -> Set[Tuple[str, str, str]]:
"""Paires ``(source, translation, lang)`` candidates pour les chemins donnes.

Pure : aucune lecture disque. Un jumeau ``xxx_<lang>.ipynb`` modifie
candidate sa source ; une source ``xxx.ipynb`` modifiee candidate tous
ses jumeaux existants potentiels (le filtre disque tranchera).
"""
candidates: Set[Tuple[str, str, str]] = set()
for path in changed:
if not path.endswith(".ipynb"):
continue
posix = PurePosixPath(path)
stem = posix.stem
parent = str(posix.parent) if str(posix.parent) != "." else ""
suffix_match = ctp._SUFFIX_RE.match(stem)
if suffix_match and suffix_match.group("lang") in ctp.TARGET_LANGS:
src_stem = suffix_match.group("stem")
src = f"{parent}/{src_stem}.ipynb" if parent else f"{src_stem}.ipynb"
candidates.add((src, path, suffix_match.group("lang")))
else:
for lang in ctp.TARGET_LANGS:
trd = f"{parent}/{stem}_{lang}.ipynb" if parent \
else f"{stem}_{lang}.ipynb"
candidates.add((path, trd, lang))
return candidates


def existing_pairs(repo_root: Path,
candidates: Set[Tuple[str, str, str]]
) -> List[Tuple[Path, Path, str, str]]:
"""Filtre les candidates dont les DEUX membres existent sur disque.

Rend ``[(source_path, translation_path, stem, lang)]`` trié pour un
rapport déterministe.
"""
kept = []
for src_rel, trd_rel, lang in sorted(candidates):
src = repo_root / Path(src_rel)
trd = repo_root / Path(trd_rel)
if src.is_file() and trd.is_file():
kept.append((src, trd, src.stem, lang))
return kept


def evaluate_pair(repo_root: Path, src: Path, trd: Path,
strict_fr: bool) -> Tuple[List[dict], List[dict]]:
"""Evalue une paire avec les invariants du porte-parole.

Rend ``(blocking, advisory)`` — anomalies serialisables. Une lecture
illisible d'un membre est bloquante (READ_ERROR), jamais silencieuse.
"""
src_cells, src_err = ctp.load_cells(src)
trd_cells, trd_err = ctp.load_cells(trd)
if src_err or trd_err:
return ([{"verdict": "READ_ERROR", "cell_id": "",
"detail": {"error": src_err or trd_err,
"source": str(src), "translation": str(trd)}}], [])
anomalies = ctp.check_invariants(src_cells, trd_cells, strict_fr=strict_fr)
blocking_verdicts = set(BLOCKING_VERDICTS)
if strict_fr:
blocking_verdicts.add("FR_CONTAM")
blocking = []
advisory = []
for a in anomalies:
record = {"verdict": a.verdict, "cell_id": a.cell_id,
"detail": a.detail}
(blocking if a.verdict in blocking_verdicts else advisory).append(record)
return blocking, advisory


def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(
description="Parite jumeau FR/<lang> scopee au diff de la PR (#19116).")
p.add_argument("--repo-root", type=Path, default=Path("."),
help="Racine du depot (defaut : cwd).")
p.add_argument("--diff", type=str, default="origin/main...HEAD",
help="Plage de diff git (defaut : origin/main...HEAD).")
p.add_argument("--strict-fr", action="store_true",
help="Promouvoir FR_CONTAM d'advisory a bloquant.")
p.add_argument("--json-only", action="store_true",
help="Sortie JSON seule (sans resume lisible).")
args = p.parse_args(argv)

repo_root = args.repo_root.resolve()
if not repo_root.is_dir():
print(f"ERROR: --repo-root introuvable : {repo_root}", file=sys.stderr)
return 2

changed = changed_notebooks(repo_root, args.diff)
if changed is None:
return 2

candidates = derive_candidate_pairs(changed)
pairs = existing_pairs(repo_root, candidates)

report = {
"diff": args.diff,
"changed_notebooks": len(changed),
"pairs_evaluated": len(pairs),
"pairs": [],
}
blocking_total = 0
for src, trd, stem, lang in pairs:
blocking, advisory = evaluate_pair(repo_root, src, trd, args.strict_fr)
blocking_total += len(blocking)
report["pairs"].append({
"source": str(src.relative_to(repo_root)),
"translation": str(trd.relative_to(repo_root)),
"stem": stem,
"lang": lang,
"blocking": blocking,
"advisory": advisory,
})

if args.json_only:
print(json.dumps(report, ensure_ascii=False, indent=1))
else:
print(f"[twin-parity] diff={args.diff} : {len(changed)} notebook(s) "
f"modifie(s), {len(pairs)} paire(s) jumeau touchee(s)")
for entry in report["pairs"]:
status = "OK" if not entry["blocking"] else "KO"
extra = f" (+{len(entry['advisory'])} advisory)" if entry["advisory"] else ""
print(f" [{status}] {entry['stem']} ({entry['lang']})"
f" : {len(entry['blocking'])} bloquant(s){extra}")
for a in entry["blocking"]:
detail = json.dumps(a["detail"], ensure_ascii=False)
print(f" {a['verdict']} cellule={a['cell_id']} {detail[:200]}")
print(f"[twin-parity] verdict : "
f"{'FAIL' if blocking_total else 'PASS'} "
f"({blocking_total} anomalie(s) bloquante(s))")

return 1 if blocking_total else 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading