From e901e724093c4a367e1f7403bf00c61591040c26 Mon Sep 17 00:00:00 2001 From: jsboige Date: Thu, 10 Sep 2026 06:08:41 +0200 Subject: [PATCH 1/2] feat(ci,#15322): translation hot-drift per-PR advisory gate A prose-realignment PR that edits the source of a cell carrying a deposited translation without resyncing its CSV row turns the hot-subset ratchet red on MAIN for the whole fleet (36 PRs at once; incidents #15253/#15136/#15216). The ratchet stays repo-wide; what was missing is attribution. New organ check_pr_translation_drift.py reuses the check_translation_sync predicate verbatim and intersects it with the merge-base delta, so only cells THIS diff puts into the hot subset flag. Advisory (blocking=False, TRANCHE2 native entry + dispatch-only stub); calibration over 98 landed notebook PRs: 6 flagged, 6/6 true positives. Self-test replays the founding occurrence (#15136, b07ca49a). Co-Authored-By: Claude Sonnet 5 --- .../translation-hot-drift-advisory.yml | 81 ++++ scripts/ci/fast_lane_registry.py | 38 ++ scripts/tests/test_fast_lane.py | 11 +- .../translation/check_pr_translation_drift.py | 418 ++++++++++++++++++ .../tests/test_check_pr_translation_drift.py | 134 ++++++ 5 files changed, 677 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/translation-hot-drift-advisory.yml create mode 100644 scripts/translation/check_pr_translation_drift.py create mode 100644 scripts/translation/tests/test_check_pr_translation_drift.py diff --git a/.github/workflows/translation-hot-drift-advisory.yml b/.github/workflows/translation-hot-drift-advisory.yml new file mode 100644 index 0000000000..b7b09483c7 --- /dev/null +++ b/.github/workflows/translation-hot-drift-advisory.yml @@ -0,0 +1,81 @@ +name: Translation Hot-Drift Advisory + +# #15322 — une PR de realignement de prose qui modifie la source d'une +# cellule portant une traduction deposee, sans resynchroniser sa ligne CSV, +# fait rougir le cliquet hot-subset (#13551) SUR MAIN — donc sur toutes les +# PRs ouvertes qui l'heritent (36 PRs rouges d'un coup ; incidents documentes +# #15253 / #15136 / #15216). La lane qui voit le rouge le lit comme son +# propre defaut et brule un cycle sur un diff qu'elle n'a pas casse. +# +# Ce garde repond, par PR, la seule question que le cliquet ne peut pas +# poser : quelles cellules CE diff fait-il entrer dans le sous-ensemble +# chaud ? Le predicat est REUTILISE tel quel de +# scripts/translation/check_translation_sync.py (contrainte de l'issue : +# pas de second predicat qui puisse diverger du cliquet qu'il protege) ; +# le delta (cellules source changees entre merge-base et tete) isole la +# contribution propre de la PR — la derive heritee de main ne flagge jamais. +# +# Placement ADVISORY (point 2 de l'issue) : le verdict est publie sous le +# check-run « Translation hot-drift (base vs PR, advisory) » avec une +# conclusion neutre, nommant les cellules. Calibration sur 98 PRs notebook +# atterries (2026-08-20 -> 2026-09-10) : 6 declenchements, 6/6 vrais +# positifs (les 3 incidents documentes + 3 atterrissages repris apres coup +# que le tableau de l'issue ne listait pas). La bascule en bloquant attend +# la poursuite de cette mesure. + +# Livré directement dans la voie rapide (#12567) : le verdict par-PR est +# rendu par `fast-lane-shadow.yml` via l'entrée TRANCHE2 de +# `scripts/ci/fast_lane_registry.py`. Ce fichier ne garde que +# `workflow_dispatch` (relance manuelle). +on: + workflow_dispatch: + inputs: + base_ref: + description: 'Base ref to ratchet against (default origin/main)' + required: false + default: 'origin/main' + +permissions: + contents: read + +concurrency: + group: translation-hot-drift-advisory-${{ github.ref }} + cancel-in-progress: true + +jobs: + advisory: + name: Translation hot-drift (base vs PR, advisory) + # Même jambe que les siblings ratchet (#14959) : garde Python pur, sans + # secret. La garde same-repo ci-dessous saute les PRs de fork : aucun + # code de fork n'atteint le runner. + runs-on: [self-hosted, coursia-ephemeral, coursia-linux] + if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + filter: blob:none + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # Le self-test passe FIRST et gate le garde : contrôles positifs, + # négatifs et d'attribution sur fixtures synthétiques + replay du cas + # fondateur (#15136, image.csv cell b07ca49a). + - name: Detector self-test (positive + negative + attribution) + run: python scripts/translation/check_pr_translation_drift.py --self-test + + - name: Translation hot-drift unit tests + run: python -m pytest scripts/translation/tests/test_check_pr_translation_drift.py -q + + # Refs reach the script via env, never inline in the run script + # (script-injection guard, same as the siblings). + - name: Hot-drift check (advisory) + env: + BASE_REF: ${{ github.event.inputs.base_ref }} + PR_BASE: ${{ github.base_ref }} + run: | + BASE="${BASE_REF:-origin/${PR_BASE:-main}}" + python scripts/translation/check_pr_translation_drift.py "$BASE" diff --git a/scripts/ci/fast_lane_registry.py b/scripts/ci/fast_lane_registry.py index c5d47ef05f..7b43e81d29 100644 --- a/scripts/ci/fast_lane_registry.py +++ b/scripts/ci/fast_lane_registry.py @@ -495,6 +495,44 @@ class Guard: absorbed=True, warn_rc=(2,), ), + # Translation hot-drift ATTRIBUTION, advisory (#15322): a prose-realignment + # PR that edits the source of a cell carrying a deposited translation + # without resyncing its CSV row turns the repo-wide hot-subset ratchet + # (#13551) red on MAIN -- and every open PR inherits the red (36 PRs at + # once; incidents #15253 / #15136 / #15216). The ratchet must stay + # repo-wide; what was missing is the per-PR question: which cells does + # THIS diff put into the hot subset? The predicate is reused verbatim from + # scripts/translation/check_translation_sync.py (issue constraint: no + # second predicate that could diverge from the ratchet it protects); the + # delta (source cells changed between merge-base and head) isolates the + # PR's own contribution, so inherited main-side drift never flags. Advisory + # per the issue: neutral conclusion naming the cells; blocking only after + # the trigger rate is measured (calibration on 98 landed notebook PRs: + # 6 flagged, 6/6 true positives -- the 3 documented incidents plus 3 + # repaired-after-the-fact landings the issue table did not list). + # Source : translation-hot-drift-advisory.yml (stub dispatch-only). + Guard( + name="Translation hot-drift (base vs PR, advisory)", + source="translation-hot-drift-advisory.yml", + paths=[ + "MyIA.AI.Notebooks/**/*.ipynb", + "translations/**/*.csv", + "scripts/translation/check_pr_translation_drift.py", + "scripts/translation/tests/test_check_pr_translation_drift.py", + ".github/workflows/translation-hot-drift-advisory.yml", + ], + pre_argv=[ + "python", "scripts/translation/check_pr_translation_drift.py", + "--self-test", + ], + argv=[ + "python", "scripts/translation/check_pr_translation_drift.py", + "{base_ref}", + ], + blocking=False, + needs_base=True, + absorbed=True, + ), ] diff --git a/scripts/tests/test_fast_lane.py b/scripts/tests/test_fast_lane.py index 31729410d4..9c3b69c7e0 100644 --- a/scripts/tests/test_fast_lane.py +++ b/scripts/tests/test_fast_lane.py @@ -671,11 +671,12 @@ def test_tranche2_guards_are_absorbed_and_declare_their_warn_rc(): d'etre plus strict que ce qu'il remplace. Le ratchet, lui, porte un pre-contrôle self-test.""" from fast_lane_registry import TRANCHE2 - assert len(TRANCHE2) == 4, ( - "la tranche 2 documente trois formes moteur portees par quatre " - "gardes (deux instances du ratchet autonome : failure-text + " - "output-flood) ; si le nombre change, le commentaire du registre " - "et ce test suivent") + assert len(TRANCHE2) == 5, ( + "la tranche 2 documente trois formes moteur portees par cinq " + "gardes (deux ratchets autonomes bloquants : failure-text + " + "output-flood ; deux gardes d'iteration figure/texte ; le ratchet " + "autonome advisory translation hot-drift #15322) ; si le nombre " + "change, le commentaire du registre et ce test suivent") for guard in TRANCHE2: assert guard.absorbed, f"{guard.name} doit porter absorbed=True" warners = {g.name for g in TRANCHE2 if g.warn_rc} diff --git a/scripts/translation/check_pr_translation_drift.py b/scripts/translation/check_pr_translation_drift.py new file mode 100644 index 0000000000..1d616610fa --- /dev/null +++ b/scripts/translation/check_pr_translation_drift.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""Advisory per-PR gate: names the cells whose SOURCE this PR modifies while +they carry a deposited translation, without the CSV row being resynced (#15322). + +The repo-wide ratchet (``scripts/translation/tests/test_hot_subset_ratchet.py``, +#13551) keeps the hot subset -- ``SRC_DRIFT`` rows with at least one non-empty +``text_`` -- at zero on ``main``. Its weakness is ATTRIBUTION, not +coverage: when a prose-realignment PR merges without resyncing the CSV, the +ratchet goes red on ``main`` and therefore on EVERY open PR that inherits it +(36 PRs red at once; documented incidents #15253 / #15136 / #15216). The lane +that sees the red reads it as its own defect and burns a cycle on a diff it +did not break. This gate answers, per PR, the only question the ratchet cannot: +did THIS PR put cells into the hot subset? + +Predicate reuse (issue constraint: no second predicate that could diverge from +the ratchet it protects): the hot classification comes from +``check_translation_sync.check_csv`` itself -- an ``SRC_DRIFT`` anomaly at head +on a row with a deposited translation. The only new ingredient is the DELTA: +cells whose ``cell_hash`` differs between the merge base and head of the +changed notebooks. A cell already drifted on the base, untouched by this PR, +never flags; a cell the PR re-modifies while it was already drifted DOES flag +(the new modification is unresynced too). The base is resolved through +``merge-base`` (never ``pull_request.base.sha``, which points at main -- the +stale-base lesson of #15427). + +Formally-synced-but-semantically-stale rows (a resync that re-pins +``text_fr``/``src_hash`` without re-translating a material change) are OUT OF +SCOPE here by design: the hot predicate is hash-formal, and judging materiality +per-cell is the suspended ruling of #6949. This gate reports exactly what the +ratchet would report, minus the inherited debt. + +Advisory per the issue (acceptance 3): findings publish under a neutral +check-run naming the cells; the switch to blocking waits on a measured trigger +rate. Wired as ``blocking=False`` in the fast lane TRANCHE2. + +Usage: + python check_pr_translation_drift.py [--json] + python check_pr_translation_drift.py --self-test + python check_pr_translation_drift.py --head [--json] # replay + +Exit 1 iff at least one cell enters the hot subset because of this diff (the +fast-lane engine renders that as an advisory neutral, never a gate). +""" + +from __future__ import annotations + +import argparse +import csv +import io +import json +import sys +import tempfile +from pathlib import Path + +THIS_DIR = Path(__file__).resolve().parent +REPO_ROOT = THIS_DIR.parent.parent +sys.path.insert(0, str(THIS_DIR)) +sys.path.insert(0, str(REPO_ROOT / "scripts" / "notebook_tools")) + +import check_translation_sync as t2 # noqa: E402 +from check_output_failure_text import ( # noqa: E402 + changed_notebooks, + git, + read_notebook_at, + resolve_base, +) +from check_perimeter import TARGET_LANGS # noqa: E402 + +# Founding occurrence replayed by --self-test (issue table): the #15136 merge +# left cell b07ca49a of image.csv behind -- source edited, translation +# deposited, CSV row untouched. The other two incidents (#15253 finetuning, +# #15216 casestudies) are replayed by the pytest suite. +SELF_TEST_BASE = "378af0229^" +SELF_TEST_HEAD = "378af0229" +SELF_TEST_NOTEBOOK = "MyIA.AI.Notebooks/GenAI/Image/01-Foundation/01-2-GPT-5-Image-Generation.ipynb" +SELF_TEST_CELLS = ("b07ca49a",) + + +def cell_hash_map(nb: dict | None) -> dict[str, str]: + """{cell_id: hash} over a PARSED notebook, mirroring t2.load_notebook_cells. + + Same cell filter and same ``t2.cell_hash`` as the CSV rows -- the parsed-JSON + variant exists only because the git plumbing hands back parsed notebooks, + while ``load_notebook_cells`` takes a path. + """ + out: dict[str, str] = {} + for cell in (nb or {}).get("cells", []) or []: + cid = cell.get("id") + if not cid or cell.get("cell_type") not in ("markdown", "code"): + continue + out[cid] = t2.cell_hash("".join(cell.get("source", []))) + return out + + +def changed_source_cells(base: str, nb_rel: str, head: str | None, + cwd: Path) -> set[str]: + """cell_ids whose source hash differs between base and head of one notebook. + + A cell NEW in head (no base counterpart) counts as changed: if a CSV row + somehow references that id, the drift is still this diff's doing. A notebook + unreadable at head returns an empty set -- a deleted/unreadable source is + ORPHAN territory (#15326), not the hot subset this gate protects. + """ + head_nb = read_notebook_at(head, nb_rel, cwd=cwd) + if head_nb is None: + return set() + base_map = cell_hash_map(read_notebook_at(base, nb_rel, cwd=cwd)) + return { + cid for cid, h in cell_hash_map(head_nb).items() + if base_map.get(cid) != h + } + + +def deposited_by_cell(csv_path: Path) -> dict[str, list[str]]: + """{cell_id: langs with a deposited text_} -- the ratchet's deposited + filter (``hot_anomalies`` in test_hot_subset_ratchet.py), kept identical.""" + out: dict[str, list[str]] = {} + with csv_path.open(encoding="utf-8-sig") as f: + for row in csv.DictReader(f): + cid = row.get("cell_id", "") + if not cid: + continue + langs = [l for l in TARGET_LANGS + if (row.get(f"text_{l}", "") or "").strip()] + if langs: + out[cid] = langs + return out + + +def evaluate(nb_changed: dict[str, set[str]], covering: dict[Path, set[str]], + repo_root: Path) -> list[dict]: + """The core: hot-at-head (via t2.check_csv) intersected with the PR delta. + + ``covering`` maps each candidate CSV to the changed notebooks it references; + ``t2.check_csv`` runs on each once (the expensive part -- it loads the + notebooks referenced by the CSV -- exactly the cost the repo-wide ratchet + already pays per PR). + """ + findings: list[dict] = [] + for csv_path, nb_rels in sorted(covering.items()): + for anomaly in t2.check_csv(csv_path, repo_root): + if anomaly.get("verdict") != "SRC_DRIFT": + continue + cid = anomaly.get("cell_id", "") + if cid not in nb_changed.get(anomaly.get("notebook", ""), ()): + continue + langs = deposited_by_cell(csv_path).get(cid, []) + if not langs: + continue + findings.append({ + "csv": csv_path.name, + "notebook": anomaly["notebook"], + "cell_id": cid, + "langs": langs, + "detail": anomaly.get("detail", ""), + }) + return findings + + +def csvs_covering_worktree(translations_dir: Path, + nb_rels: set[str]) -> dict[Path, set[str]]: + """Scan the working-tree CSVs for rows referencing any changed notebook.""" + covering: dict[Path, set[str]] = {} + if not translations_dir.is_dir(): + return covering + for csv_path in sorted(translations_dir.rglob("*.csv")): + hits = set() + with csv_path.open(encoding="utf-8-sig") as f: + for row in csv.DictReader(f): + if row.get("notebook") in nb_rels: + hits.add(row["notebook"]) + if hits: + covering[csv_path] = hits + return covering + + +def run(base_ref: str, head_ref: str | None = None, + repo_root: Path | None = None) -> dict: + """Live (or replayed) evaluation: delta first, then the reused predicate.""" + repo_root = (repo_root or Path.cwd()).resolve() + head = head_ref or "HEAD" + if head_ref is None: + # Live mode: merge-base against the checked-out branch (never + # pull_request.base.sha, which points at main -- #15427). + base = resolve_base(base_ref, cwd=repo_root) + else: + # Replay: merge-base between the two historical refs. + out = git("merge-base", base_ref, head_ref, cwd=repo_root) + base = out.strip() if out and out.strip() else base_ref + changed_nbs = changed_notebooks(base, head=head, cwd=repo_root) + nb_changed: dict[str, set[str]] = {} + for nb_rel in changed_nbs: + cells = changed_source_cells(base, nb_rel, head, repo_root) + if cells: + nb_changed[nb_rel] = cells + + result: dict = { + "base_ref": base_ref, "merge_base": base, "head": head, + "changed_notebooks": changed_nbs, + "covered_notebooks": sorted(nb_changed), + } + + if not nb_changed: + result["flagged"] = [] + return result + + if head_ref is None: + covering = csvs_covering_worktree( + repo_root / "translations", set(nb_changed)) + result["flagged"] = evaluate(nb_changed, covering, repo_root) + result["csvs_scanned"] = len(covering) + return result + + # Replay at a historical sha: materialize the CSV + the CHANGED notebooks + # at that sha in a temp snapshot. Rows referencing unchanged notebooks + # become ORPHAN in the snapshot and are filtered out -- their hot status + # cannot enter the findings anyway (the delta intersection is empty). + with tempfile.TemporaryDirectory(prefix="pr-translation-drift-") as tmp: + tmp_root = Path(tmp) + covering: dict[Path, set[str]] = {} + for csv_path in sorted((repo_root / "translations").rglob("*.csv")): + rel = csv_path.relative_to(repo_root).as_posix() + raw = git("show", f"{head_ref}:{rel}", cwd=repo_root) + if raw is None: + continue # CSV did not exist at that sha + hits = set() + # StringIO, not splitlines(): quoted fields carry embedded + # newlines (translations are multi-line) and csv must see them. + for row in csv.DictReader(io.StringIO(raw)): + if row.get("notebook") in nb_changed: + hits.add(row["notebook"]) + if not hits: + continue + snap_csv = tmp_root / rel + snap_csv.parent.mkdir(parents=True, exist_ok=True) + with snap_csv.open("w", encoding="utf-8", newline="") as f: + f.write(raw) + covering[snap_csv] = hits + for nb_rel in nb_changed: + raw = git("show", f"{head_ref}:{nb_rel}", cwd=repo_root) + if raw is None: + continue + snap_nb = tmp_root / nb_rel + snap_nb.parent.mkdir(parents=True, exist_ok=True) + with snap_nb.open("w", encoding="utf-8", newline="") as f: + f.write(raw) + result["flagged"] = evaluate(nb_changed, covering, tmp_root) + result["csvs_scanned"] = len(covering) + return result + + +# --------------------------------------------------------------------------- +# Self-test: a detector that cannot be shown to fire is indistinguishable from +# one that is unplugged (same contract as the output ratchets, #11685/#12817). +# --------------------------------------------------------------------------- + +def _write_nb(path: Path, cells: list[tuple[str, str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + nb = { + "cells": [ + {"cell_type": "markdown", "id": cid, "metadata": {}, + "source": text.splitlines(keepends=True)} + for cid, text in cells + ], + "metadata": {}, "nbformat": 4, "nbformat_minor": 5, + } + path.write_text(json.dumps(nb, ensure_ascii=False), encoding="utf-8") + + +def _write_csv(path: Path, rows: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + header = ["notebook", "cell_id", "cell_type", "src_lang", "src_hash", + "text_fr", "text_en", "text_es", "text_ar", "text_fa", + "text_zh", "text_ru", "text_pt", "hash_fr", "hash_en", + "hash_es", "hash_ar", "hash_fa", "hash_zh", "hash_ru", + "hash_pt", "translate_policy"] + with path.open("w", encoding="utf-8", newline="") as f: + w = csv.DictWriter(f, fieldnames=header, lineterminator="\n") + w.writeheader() + for r in rows: + w.writerow({k: r.get(k, "") for k in header}) + + +def _synthetic_repo(tmp: Path) -> tuple[Path, Path]: + """One notebook, three cells, one CSV: hot / pivot-only / synced.""" + nb_rel = "series/demo.ipynb" + _write_nb(tmp / nb_rel, [ + ("cell-hot", "# Titre chaud\n\nTexte actuel du hot."), + ("cell-pivot", "# Titre pivot\n\nTexte actuel du pivot."), + ("cell-sync", "# Titre sync\n\nTexte actuel du sync."), + ]) + current = t2.cell_hash("# Titre sync\n\nTexte actuel du sync.") + csv_path = tmp / "translations" / "demo.csv" + _write_csv(csv_path, [ + {"notebook": nb_rel, "cell_id": "cell-hot", + "cell_type": "markdown", "src_lang": "fr", + "src_hash": "deadbeefdeadbeef", + "text_fr": "# Titre chaud\n\nTexte ancien.", + "text_en": "# Hot title\n\nOld text."}, + {"notebook": nb_rel, "cell_id": "cell-pivot", + "cell_type": "markdown", "src_lang": "fr", + "src_hash": "deadbeefdeadbeef", + "text_fr": "# Titre pivot\n\nTexte ancien."}, + {"notebook": nb_rel, "cell_id": "cell-sync", + "cell_type": "markdown", "src_lang": "fr", + "src_hash": current, + "text_fr": "# Titre sync\n\nTexte actuel du sync.", + "text_en": "# Sync title\n\nCurrent text."}, + ]) + return csv_path, tmp / nb_rel + + +def self_test(cwd: Path | None = None) -> int: + """Positive + negative control on synthetic fixtures, then the founding + replay (#15136: image.csv cell b07ca49a) when the repo history is present.""" + failures: list[str] = [] + + with tempfile.TemporaryDirectory(prefix="t2-selftest-") as tmp_s: + tmp = Path(tmp_s) + csv_path, _ = _synthetic_repo(tmp) + + # Positive control: all three cells changed by the "PR" -> only the + # cell with a deposited translation flags. + flagged = evaluate( + {"series/demo.ipynb": {"cell-hot", "cell-pivot", "cell-sync"}}, + {csv_path: {"series/demo.ipynb"}}, tmp) + ids = sorted(f["cell_id"] for f in flagged) + if ids != ["cell-hot"]: + failures.append(f"controle positif: attendu ['cell-hot'], eu {ids}") + + # Attribution control: the same repo state, PR touches nothing -> + # pre-existing drift never flags. + flagged = evaluate({"series/demo.ipynb": set()}, + {csv_path: {"series/demo.ipynb"}}, tmp) + if flagged != []: + failures.append( + f"controle attribution: la derive pre-existante a flagge: {flagged}") + + # Re-modification control: a cell ALREADY drifted on the base, whose + # source the PR changes again, must flag (the new edit is unresynced). + flagged = evaluate({"series/demo.ipynb": {"cell-hot", "cell-pivot"}}, + {csv_path: {"series/demo.ipynb"}}, tmp) + ids = sorted(f["cell_id"] for f in flagged) + if ids != ["cell-hot"]: + failures.append(f"controle re-modification: attendu ['cell-hot'], eu {ids}") + + # Founding replay (#15136). Hermetic contexts (no git history) degrade to + # a note -- the synthetic controls above still prove the detector fires. + if cwd is None: + cwd = REPO_ROOT + if git("rev-parse", "--verify", "--quiet", SELF_TEST_HEAD + "^{commit}", + cwd=cwd) is not None: + res = run(SELF_TEST_BASE, head_ref=SELF_TEST_HEAD, repo_root=cwd) + got = tuple(sorted(f["cell_id"] for f in res["flagged"])) + if got != SELF_TEST_CELLS: + failures.append( + f"replay {SELF_TEST_HEAD}: attendu {SELF_TEST_CELLS}, eu {got}") + else: + print("NOTE: historique git absent -- replay du cas fondateur saute " + "(controles synthetiques uniquement).") + + if failures: + for f in failures: + print("SELF-TEST FAIL: " + f, file=sys.stderr) + return 1 + print("self-test OK: positif/negatif/attribution + replay fondateur.") + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser( + description="Advisory gate: cells entering the translation hot subset " + "because of THIS PR (source edited under a deposited " + "translation, CSV row not resynced). Issue #15322.") + ap.add_argument("base", nargs="?", + help="Base git ref (CI: origin/)") + ap.add_argument("--head", + help="Head sha for replay (default: working tree HEAD)") + ap.add_argument("--self-test", action="store_true", + help="Positive + negative + attribution controls, then " + "replay of the founding occurrence (#15136)") + ap.add_argument("--json", action="store_true", dest="as_json") + args = ap.parse_args(argv) + + if args.self_test: + return self_test() + + if not args.base: + ap.error("base ref required (or --self-test)") + + res = run(args.base, head_ref=args.head) + flagged = res["flagged"] + + if args.as_json: + print(json.dumps(res, indent=2, ensure_ascii=False)) + else: + print(f"base {args.base} -> merge-base {res['merge_base'][:12]}" + f" | {len(res['changed_notebooks'])} changed notebook(s)" + f" | {len(res['covered_notebooks'])} covered by translations/" + f" | {len(flagged)} cell(s) flagged (advisory)") + for f in flagged: + print(f"\nFLAGGED (advisory) {f['notebook']} cell {f['cell_id']}" + f" [{', '.join(f['langs'])}] via {f['csv']}") + print(" Cette PR modifie la source d'une cellule portant une" + " traduction deposee sans resynchroniser sa ligne CSV.") + if flagged: + print("\nRemede per-cell (mise a jour text_fr/src_hash/hash_fr +" + " re-traduction si le changement est materiel) -- JAMAIS la" + " re-extraction globale, qui orpheline les traductions" + " deposees (#13551).") + else: + print("AUCUNE cellule n'entre dans le sous-ensemble chaud du fait" + " de cette PR.") + return 1 if flagged else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/translation/tests/test_check_pr_translation_drift.py b/scripts/translation/tests/test_check_pr_translation_drift.py new file mode 100644 index 0000000000..cc23416cf9 --- /dev/null +++ b/scripts/translation/tests/test_check_pr_translation_drift.py @@ -0,0 +1,134 @@ +"""Per-PR translation hot-drift attribution gate (#15322). + +The repo-wide ratchet (``test_hot_subset_ratchet.py``, #13551) keeps the hot +subset at zero on main; its weakness is attribution -- when a prose-realignment +PR lands without resyncing the CSV, every open PR inherits the red. This gate +answers, per PR: which cells does THIS diff put into the hot subset? + +The tests pin, in order of increasing scope: +- the exact flag set on a synthetic repo (hot flags; pivot-only and synced + rows stay silent), +- the ATTRIBUTION property (pre-existing base drift, untouched by the PR, + never flags; a re-modified drifted cell does), +- a NEW cell without any CSV row never flags, +- the replay of the three documented incidents (#15253 / #15136 / #15216) at + their landing commits -- exact cell sets, the acceptance-4 positive control + (a guard is validated by its false negatives, not its hits). + +stdlib-only, no network. Mirrors the skip style of the sibling tests. +""" +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +TRANSLATION_DIR = HERE.parent +REPO_ROOT = TRANSLATION_DIR.parent.parent +sys.path.insert(0, str(TRANSLATION_DIR)) +sys.path.insert(0, str(REPO_ROOT / "scripts" / "notebook_tools")) + +import check_pr_translation_drift as g # noqa: E402 + +# Documented incidents (issue #15322 table), replayed at their landing +# commits: (head sha, expected flagged cell ids). +DOCUMENTED_INCIDENTS = [ + ("917fbe0d5", {"cell-70d4b526", "cell-ebb412ad"}), # #15253 finetuning + ("378af0229", {"b07ca49a"}), # #15136 image + ("272cbf3a7", {"43b69f3c", "fcebb55c", "8ba2019f", "a4b715be"}), # #15216 +] + + +@pytest.fixture() +def synthetic(tmp_path): + csv_path, _ = g._synthetic_repo(tmp_path) + return tmp_path, csv_path + + +def _flagged_ids(repo, nb_changed): + csv_path = repo / "translations" / "demo.csv" + return sorted( + f["cell_id"] for f in g.evaluate( + nb_changed, {csv_path: set(nb_changed)}, repo) + ) + + +def test_hot_cell_flags_pivot_and_synced_stay_silent(synthetic): + repo, _ = synthetic + ids = _flagged_ids(repo, {"series/demo.ipynb": + {"cell-hot", "cell-pivot", "cell-sync"}}) + assert ids == ["cell-hot"], ( + "seule la cellule derivee portant une traduction deposee doit flagger ;" + f" recu {ids}" + ) + + +def test_attribution_preexisting_drift_never_flags(synthetic): + """La derive pre-existante sur la base, non touchee par la PR, est + invisible : c'est la propriete d'attribution qui manque au cliquet.""" + repo, _ = synthetic + assert _flagged_ids(repo, {"series/demo.ipynb": set()}) == [] + + +def test_remodified_drifted_cell_still_flags(synthetic): + """Une cellule DEJA derivee sur la base, dont la PR re-modifie la source, + doit flagger : la nouvelle modification est non-resynchronisee aussi. + (Une difference d'ensembles hot@head - hot@base la raterait.)""" + repo, _ = synthetic + ids = _flagged_ids(repo, {"series/demo.ipynb": {"cell-hot", "cell-pivot"}}) + assert ids == ["cell-hot"] + + +def test_new_cell_without_csv_row_never_flags(synthetic): + repo, _ = synthetic + ids = _flagged_ids(repo, {"series/demo.ipynb": {"cell-brand-new"}}) + assert ids == [] + + +def test_resynced_row_leaves_no_hot_anomaly(synthetic): + """Le remede par-cellule (resynchro CSV) eteint le flag -- preuve que le + signal suit le geste attendu de l'auteur, pas la forme du diff.""" + repo, _ = synthetic + csv_path = repo / "translations" / "demo.csv" + current = g.t2.cell_hash("# Titre chaud\n\nTexte actuel du hot.") + import csv as _csv + with csv_path.open(encoding="utf-8-sig") as f: + rows = list(_csv.DictReader(f)) + for r in rows: + if r["cell_id"] == "cell-hot": + r["src_hash"] = current + r["hash_fr"] = current + g._write_csv(csv_path, rows) + ids = _flagged_ids(repo, {"series/demo.ipynb": {"cell-hot"}}) + assert ids == [] + + +def test_replay_documented_incidents(): + """Controle positif (acceptance 4) : les trois incidents documentes + rejoues a leur atterrissage flaggent EXACTEMENT les cellules listees. + Saute si l'historique est absent (clone shallow / hermetique).""" + if not (REPO_ROOT / "translations").is_dir(): + pytest.skip("repo tree not present") + for sha, expected in DOCUMENTED_INCIDENTS: + probe = g.git("rev-parse", "--verify", "--quiet", sha + "^{commit}", + cwd=REPO_ROOT) + if probe is None: + pytest.skip(f"{sha} absent de l'historique local") + res = g.run(sha + "^", head_ref=sha, repo_root=REPO_ROOT) + got = {f["cell_id"] for f in res["flagged"]} + assert got == expected, ( + f"replay {sha}: attendu {sorted(expected)}, recu {sorted(got)}" + ) + + +def test_self_test_passes(): + if not (REPO_ROOT / "translations").is_dir(): + pytest.skip("repo tree not present") + assert g.self_test(cwd=REPO_ROOT) == 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From d6e894ea0e5339c1070d6395c09cb7eb05810eff Mon Sep 17 00:00:00 2001 From: jsboige Date: Thu, 10 Sep 2026 06:31:21 +0200 Subject: [PATCH 2/2] fix(ci,#15322): allowlist self-hosted policy for hot-drift advisory stub Le stub workflow_dispatch self-hosted n'etait pas dans l'allowlist canonique (WORKFLOW_NOT_ALLOWED prouve par le run CPU 34436118077) : entree SELF_HOSTED_WORKFLOW_ALLOWLIST commentee (profil repeated-prose/ markdown-decent : cible d'identite fast-lane TRANCHE2 + re-run manuel du self-test) et test de politique dedie. Merge de main au passage (apporte la reparation #15341 : le rouge herite medical_chatbot_en disparait de l'arbre). Pin TRANCHE2 recompte sur l'etat effectif post-merge : 5 gardes. Suites locales : scanner policy rc=0, 58 tests policy, 137 tests (detecteur + fast-lane + policy), self-test OK. See #15322 Co-Authored-By: Claude Sonnet 5 --- scripts/ci/check_self_hosted_runner_policy.py | 12 ++++++++++ .../test_check_self_hosted_runner_policy.py | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/scripts/ci/check_self_hosted_runner_policy.py b/scripts/ci/check_self_hosted_runner_policy.py index ab7d4f3954..aec6caf398 100644 --- a/scripts/ci/check_self_hosted_runner_policy.py +++ b/scripts/ci/check_self_hosted_runner_policy.py @@ -314,6 +314,18 @@ # (LINUX_RUNNER_LABELS, meme profil que md-content-loss-gate tranche 5 # #13378). Rollback = revert de la PR (l'entree disparait de l'allowlist). "notebook-plan-loss-gate.yml", + # #15322 (owner myia-po-2023:CoursIA) : vehicule workflow_dispatch-ONLY + # servant de cible d'identite au check-run absorbe par fast-lane + # (registre TRANCHE2) et de re-run manuel du self-test sur main. + # Detecteur check_pr_translation_drift.py (attribution par merge-base, + # predicat REUTILISE de check_translation_sync.check_csv -- aucune + # reimplementation), advisory pur-Python stdlib-only, conclusion neutre, + # jamais exit != 0 sur le verdict, aucun secret, aucun GITHUB_TOKEN cote + # job. Runner = jambe Linux containerisee (LINUX_RUNNER_LABELS, meme + # profil que repeated-prose-advisory.yml / markdown-deaccent- + # advisory.yml). Rollback = revert de la PR (l'entree disparait de + # l'allowlist). + "translation-hot-drift-advisory.yml", } GITHUB_HOSTED_LABELS = { "ubuntu-latest", diff --git a/scripts/tests/test_check_self_hosted_runner_policy.py b/scripts/tests/test_check_self_hosted_runner_policy.py index 33886912c0..7ec56b3393 100644 --- a/scripts/tests/test_check_self_hosted_runner_policy.py +++ b/scripts/tests/test_check_self_hosted_runner_policy.py @@ -422,6 +422,28 @@ def test_mixed_lean_and_linux_labels_are_rejected(tmp_path): assert codes(result) == {"RUNNER_LABELS"} +def test_translation_hot_drift_advisory_is_allowlisted(tmp_path): + """#15322/#15438 : le vehicule workflow_dispatch-ONLY du garde hot-drift + par-PR (absorbe fast-lane TRANCHE2) est autorise sur la jambe Linux + containerisee -- precedent repeated-prose-advisory / markdown-deaccent + (cible d'identite + re-run manuel du self-test). Son non-allowlistage + etait exactement le WORKFLOW_NOT_ALLOWED prouve par le run CPU frais.""" + assert "translation-hot-drift-advisory.yml" in policy.SELF_HOSTED_WORKFLOW_ALLOWLIST + write_workflow(tmp_path, "translation-hot-drift-advisory", """ + name: translation-hot-drift + on: workflow_dispatch + jobs: + advisory: + runs-on: [self-hosted, coursia-ephemeral, coursia-linux] + steps: + - run: echo safe + """) + result = policy.scan_workflows(tmp_path) + assert result.broken == [] + assert result.violations == [] + assert result.self_hosted_jobs == 1 + + def test_mixed_linux_and_fast_guards_labels_are_rejected(tmp_path): # Mixing the two dedicated sets must stay a violation: a job eligible # for both the Windows runners and the Linux container would make the