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
2 changes: 2 additions & 0 deletions .claude/rules/git-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ GitHub auto-closes issues on `Refs #N`, `Fixes #N`, `Closes #N`. Use safe syntax
4. le coordinateur merge **immédiatement**, et **la branche est gelée entre 3 et 4**.

Le gel est la pièce qui manquait : un dossier a besoin d'une **branche silencieuse**, sinon le travail de prévalidation est détruit par le travail de réparation, indéfiniment.

La forme **opérationnelle** du contrat exact-head vit dans [`coordinate/SKILL.md`](../skills/coordinate/SKILL.md) (« un changement de head ou de surface le perime ») et n'est **pas** reformulée ici — deux surfaces qui redécrivent la même règle finissent par diverger (#16962). Verbatim de l'organe et réconciliation de l'issue fondatrice : [prevalidation-dossier-order-detail.md](../../docs/reference/prevalidation-dossier-order-detail.md).
- **`--force-with-lease` plutôt que `--force`** : il échoue si le remote a bougé depuis ta dernière lecture — précisément le cas « une autre lane a poussé sans que je le sache ». C'est le garde-fou qui rend le périmètre ci-dessus sûr.
- **Jamais de `reset --hard`** sur `main` ni sur une branche partagée.
- **Un secret déjà commité ne se répare PAS par réécriture d'historique** : branche propre + cherry-pick, et **rotation de la clé** (cf [secrets-hygiene.md](secrets-hygiene.md) règle 5).
Expand Down
97 changes: 97 additions & 0 deletions docs/reference/prevalidation-dossier-order-detail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Prévalidation — pourquoi l'ordre compte, et ce que la tête vient périmer (#16878)

Détail déporté de la puce « `update-branch` tue AUSSI le dossier de prévalidation » de [`git-workflow.md`](../../.claude/rules/git-workflow.md). Le sujet : **le dossier de prévalidation atteste une tête, pas une PR** — et l'ordre des gestes qui en découle.

**La règle fait foi** pour l'ordre et le gel ; ce document ne les rejoue pas, il donne le **pourquoi** (le mécanisme vérifié à la source, la boucle réelle, et les deux prémisses de l'issue fondatrice qui étaient fausses).

## 1. La mesure fondatrice — 17 candidates sur 17

**Rapporté par l'issue #16878** (2026-09-19), non re-mesuré ici : le gate d'entrée de la passe de merge (`scripts/check_adjoint_prevalidation.py`) a rendu `exit 1` sur **17 candidates sur 17**, et **aucun** de ces refus ne portait sur un défaut de PR. Les trois motifs observés, verbatim de l'issue :

```
- head is stale: dossier=174bf0db..., live=caeec4d9...
- discussion surfaces changed or were not fully attested
- diff-files is stale: dossier=2, live=1
```

Distinction à tenir : le **fait** « 17/17, aucun défaut de PR » est un témoignage daté ; ce qui est **vérifié ici** est le **mécanisme** qui le produit (§2) — et c'est le mécanisme qui justifie la règle. Une règle qui ne s'appuierait que sur le compte serait un argument d'autorité ; elle s'appuie sur le code.

## 2. Le mécanisme, vérifié à la source

`scripts/check_adjoint_prevalidation.py` épingle dans le dossier des **surfaces mesurées à l'instant T**, puis refuse si elles ont bougé.

Surfaces comparées — `diff-files`, `diff-additions`, `diff-deletions` :

```python
# l.548-550
"diff-files": snapshot["changedFiles"],
"diff-additions": snapshot["additions"],
"diff-deletions": snapshot["deletions"],
```

Le refus, générique sur toute surface comptée :

```python
# l.552-554
for key, live_value in comparisons.items():
if integers.get(key) is not None and integers[key] != live_value:
errors.append(f"{key} is stale: dossier={integers[key]}, live={live_value}")
```

Le refus sur la tête — la surface qui décide de tout :

```python
# l.556-558
if f.get("head") != snapshot["headRefOid"]:
errors.append(
f"head is stale: dossier={f.get('head', '?')}, live={snapshot['headRefOid']}"
)
```

Et la surface des discussions, qui n'est pas un compte :

```python
# l.534
"discussion surfaces changed or were not fully attested: "
```

**Conséquence** : `gh pr update-branch` crée un commit de fusion, donc **change la tête**. Le dossier écrit avant atteste `caeec4d9…` alors que la PR vit à `174bf0db…` : `head is stale`. Le gate refuse — **à raison**. Le défaut n'est pas dans le gate, il est dans **l'ordre** : le protocole demandait le dossier **avant** la stabilisation de la branche.

## 3. La boucle, et où elle se referme réellement

Le raisonnement de l'issue #16878 était :

1. `main` devient rouge → les lanes doivent `update-branch` pour récupérer le correctif. **C'est le bon geste.**
2. `update-branch` change la tête → le dossier est périmé, les comptes de diff bougent.
3. le même `update-branch` ré-arme le DWELL de 120 min → `PR gate` rouge.
4. DWELL rouge → l'adjoint ne peut pas attester `checks: latest-wins-green` → il **retient** le dossier, à juste titre.
5. pas de dossier → `exit 1` → pas de merge → la PR vieillit → il faut re-`update-branch`.

**L'étape 3 est fausse dans le cas courant, et c'est la correction apportée ici.** Depuis **#16149**, `scripts/ci/merge_dwell.py` mesure le plancher par `last_authoritative_committed_at` : la date de **committer** du dernier commit qui **modifie le côté PR**. Une fusion de rafraîchissement de base **prouvée content-free** (deux parents · second parent ancêtre de la base · arbre identique à l'auto-merge) est **sautée** — le plancher est donc **inchangé** par un `update-branch` **sans conflit**, qui est le cas ordinaire. La règle portait cette affirmation périmée jusqu'à #16962/#17286.

**La boucle se referme donc par l'étape 2, pas par l'étape 3.** C'est suffisant pour la bloquer : la péremption du dossier par changement de tête ne dépend pas du DWELL. Le DWELL reste une raison d'**attendre** (le plancher issu des commits de contenu est toujours là) — jamais une raison de ré-écrire un dossier.

Ce détail compte pour la suite : une issue qui motive un protocole par un mécanisme faux reste dangereuse même quand le protocole est bon, parce que la prochaine personne à toucher le sujet réintroduira l'erreur en toute bonne foi.

## 4. Ce qui était déjà écrit, et où

L'issue écrit : « **L'autre moitié n'est écrite nulle part** ». C'est **inexact** — la péremption par changement de tête/surface est déjà portée par [`coordinate/SKILL.md`](../../.claude/skills/coordinate/SKILL.md) :

> un contrat machine-lisible exact-head, trois surfaces B.0, checks latest-wins, scope, domaine et verdict ; **un changement de head ou de surface le perime**.

Ce qui manquait n'est donc pas le **fait**, c'est **l'ordre** — et le fait qu'il soit nommé comme la condition qui débloque la boucle. La règle **renvoie** au skill pour le fait plutôt que de le redécrire : deux surfaces qui reformulent la même règle finissent par diverger, c'est précisément le défaut de #16962.

## 5. Pourquoi cet ordre — et pas un autre

L'ordre en 4 temps lui-même est porté par la règle (puce « `update-branch` tue AUSSI le dossier de prévalidation ») ; il n'est **pas** recopié ici, pour la raison du §4.

Ce qui mérite d'être explicité, c'est **pourquoi le gel en est la pièce centrale** : c'est la seule qui ne se déduit pas du mécanisme. Un dossier a besoin d'une **branche silencieuse** — sans gel, le travail de prévalidation est détruit par le travail de réparation, et le gel ne peut pas se déduire de « le dossier atteste une tête », il faut le **décider**.

Le blocage est structurel : **chacun fait exactement ce que son rôle prescrit** — la lane rafraîchit (bon geste), l'adjoint atteste à la tête exacte (sa fonction), le coordinateur exige un dossier valide (le gate). Aucun des trois gestes n'est fautif ; le défaut est dans leur **séquence**, donc aucun des trois ne peut en sortir seul. C'est ce qui rend l'ordre — et non le constat — la livraison.

## Voir aussi

- [`.claude/rules/git-workflow.md`](../../.claude/rules/git-workflow.md) — la règle : les deux moitiés du mécanisme `update-branch`, l'ordre, le gel
- [`scripts/check_adjoint_prevalidation.py`](../../scripts/check_adjoint_prevalidation.py) — le gate, et ses trois codes de sortie
- [`.claude/skills/coordinate/SKILL.md`](../../.claude/skills/coordinate/SKILL.md) — la passe de merge, la péremption par tête/surface
- [#16962 / #17286](https://github.com/jsboige/CoursIA/issues/16962) — le prédicat DWELL réel, et pourquoi la règle l'avait périmé
207 changes: 207 additions & 0 deletions scripts/tests/test_prevalidation_order_rule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"""Epingle : la regle porte l'ORDRE du dossier de prevalidation une seule fois, et juste.

Pourquoi ce fichier existe
--------------------------
#16878. Le 2026-09-19, le gate d'entree de la passe de merge a refuse **17 candidates
sur 17**, aucune pour un defaut de PR : le dossier de prevalidation atteste une
**tete**, et `gh pr update-branch` change la tete.

L'issue fondatrice motivait la boucle par un mecanisme qui est **faux depuis
#16149** : « `update-branch` re-arme le plancher DWELL pour 120 min ». Un
rafraichissement de base content-free est **saute** par le predicat
`last_authoritative_committed_at` — le plancher reste **inchange**.

Apres la reduction du 2026-09-23 (reserve #17289), la livraison de cette branche
est **l'epingle elle-meme**, plus une seconde redaction : #16879 puis #16963
avaient deja mis sur `main` — dans la puce « `update-branch` tue AUSSI le dossier
de prevalidation » — l'ordre en 4 temps, le gel, la mesure fondatrice ET la
correction #16962 du claim DWELL.

Les trois risques que cette epingle ferme :

1. **reintroduire la claim DWELL fausse** en « completant » la regle de bonne foi
(c'est ce que #16962/#17286 venaient de retirer — et ce que l'etape 2 de la
section ajoutee par cette branche avait fait) ;
2. **perdre l'ordre en 4 temps**, seule livraison qui debloque la boucle, au
profit du seul constat « la tete change » ;
3. **ouvrir une seconde surface** qui redit la meme regle : deux redactions
divergent, c'est le defaut de #16962 sur ce meme fichier.

Chaque test a son **controle negatif** : sans lui, une epingle qui passe toujours
serait indiscernable d'un test vide.

Ce que l'epingle ne couvre PAS : la classe entiere « une regle cite un organe et
se trompe ». Elle couvre l'instance et empeche sa regression.
"""
import re
import unicodedata
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
RULE = REPO_ROOT / ".claude" / "rules" / "git-workflow.md"
DETAIL = REPO_ROOT / "docs" / "reference" / "prevalidation-dossier-order-detail.md"
ORGAN = REPO_ROOT / "scripts" / "check_adjoint_prevalidation.py"

#: La puce qui porte la seconde moitie du mecanisme — surface UNIQUE depuis #17289.
_BULLET = "\n- **`update-branch` tue AUSSI le dossier de prévalidation"
#: Un titre de sous-section qui redirait la meme regle (#17289 : a ne pas rouvrir).
_SECOND_SURFACE = re.compile(r"\n### [^\n]*dossier de prévalidation")

#: La claim DWELL perimee — celle que la puce ne doit PAS rapporter.
#: (Verbatim de la formulation d'origine, conservee pour le controle negatif.)
_STALE_DWELL = re.compile(
r"update-branch\D{0,40}remet\s+le\s+plancher\s+DWELL\s+.{0,40}\s*z[eé]ro",
re.IGNORECASE | re.DOTALL,
)
_OLD_CLAIM = (
"- **`gh pr update-branch` remet le plancher DWELL à zéro** (#15859) : le "
"plancher de merge (120 min, `scripts/ci/merge_dwell.py`) se mesure depuis "
"le **dernier commit** de la branche, et `update-branch` en crée un."
)


def _norm(text: str) -> str:
"""Normalise espaces, accents, casse ET balisage (meme discipline que l'epingle DWELL).

Le balisage (`*`, backticks) est retire parce que les epingles ci-dessous
visent le CONTENU des claims, pas leur mise en forme : un marqueur qui
casserait sur un `**gras**` ajoute plus tard mesurerait la typographie au
lieu de la regle.
"""
folded = unicodedata.normalize("NFKD", text)
folded = "".join(c for c in folded if not unicodedata.combining(c))
folded = folded.replace("*", "").replace("`", "")
return re.sub(r"\s+", " ", folded).lower()


def _rule_text() -> str:
return RULE.read_text(encoding="utf-8")


def _order_section() -> str:
"""La PUCE « update-branch tue AUSSI le dossier », bornee a la puce suivante.

L'ancrage est le debut de la puce, pas la phrase nue : un `find` sur
« dossier de prévalidation » attraperait la premiere occurrence du texte, et
une mention de la phrase ailleurs dans le fichier ferait deriver la borne --
l'epingle testerait alors un voisinage sans rapport en le declarant vert.

Sans borne haute, une assertion « tel mot est absent » pourrait de meme etre
satisfaite (ou cassee) par du texte sans rapport.
"""
text = _rule_text()
start = text.find(_BULLET)
assert start >= 0, "la puce « update-branch tue AUSSI le dossier » a disparu"
rest = text[start + 1:]
following = re.search(r"\n- \*\*", rest)
return rest[: following.start()] if following else rest


# ------------------------------- 1. la puce ne rapporte pas la claim DWELL perimee

def test_bullet_does_not_restate_the_stale_dwell_claim():
"""#16149 : un rafraichissement content-free laisse le plancher INCHANGE."""
assert _STALE_DWELL.search(_norm(_order_section())) is None


def test_negative_control_the_old_dwell_wording_is_flagged():
"""Controle negatif — sans lui, l'epingle ci-dessus serait un test vide."""
assert _STALE_DWELL.search(_norm(_OLD_CLAIM)) is not None


# ------------------------------------- 2. une seule surface porte la regle (#17289)

def test_rule_does_not_open_a_second_surface_for_the_same_rule():
"""#17289 : la branche ajoutait une sous-section qui redit l'ordre de la puce.

Mesure firsthand de la reserve : la section `### update-branch et le dossier
de prevalidation` (l.51-70 de la branche) reprenait l'ordre en 4 temps, le
gel ET la mesure fondatrice deja portes par la puce l.43-52 -- et son etape 2
redisait la claim DWELL que #16962 venait de corriger. La duplication n'est
pas seulement du poids : elle **re-derive**.
"""
text = _rule_text()
assert _SECOND_SURFACE.search(text) is None, (
"une seconde surface redit la regle : la puce est la surface unique"
)
assert text.count(_BULLET) == 1, "la puce est dupliquee dans la regle"


def test_negative_control_a_synthetic_second_section_is_flagged():
"""Controle negatif : le detecteur mord sur la forme qu'il pretend interdire."""
synthetic = "\n### update-branch et le dossier de prévalidation — l'ordre\n\ntexte\n"
assert _SECOND_SURFACE.search(synthetic) is not None


# ------------------------------------------------- 3. la seconde moitie est portee

def test_bullet_names_the_attested_surface_as_the_head():
"""Le fait qui fonde tout : le dossier atteste une TETE, pas une PR."""
bullet = _norm(_order_section())
assert "exact-head" in bullet
assert "perime" in bullet
assert "head is stale" in bullet


def test_bullet_cross_references_instead_of_restating():
"""Anti-derive (#16962) : le fait est deja dans le skill -> renvoi, pas copie.

Le renvoi doit ETRE un lien, pas une simple mention : c'est ce qui rend la
surface unique verifiable.
"""
bullet = _order_section()
assert "coordinate/SKILL.md" in bullet, "le renvoi au skill a disparu"
assert "../skills/coordinate/SKILL.md" in bullet, "le renvoi n'est plus un lien"


# -------------------------------------------------------- 4. l'ordre en 4 temps

def test_bullet_carries_the_four_step_order():
bullet = _norm(_order_section())
for marker in ("si elle doit recuperer main", "on rejoue",
"alors l'adjoint ecrit le dossier", "merge immediatement"):
assert marker in bullet, "etape absente de l'ordre : {}".format(marker)
assert "personne ne re-pousse" in bullet, "le garde anti-push a disparu"


def test_bullet_names_the_branch_freeze_as_the_condition():
"""Le gel est la seule piece qui ne se deduit pas du mecanisme."""
bullet = _norm(_order_section())
assert "gelee entre 3 et 4" in bullet
assert "silencieuse" in bullet, "la raison du gel (branche silencieuse) a disparu"


def test_bullet_cites_the_founding_measurement():
"""Sans la mesure, la regle se relit comme une precaution theorique."""
assert "17 candidates sur 17" in _norm(_order_section())


# ------------------------------------------------- 5. le detail porte les preuves

def test_detail_doc_exists_and_quotes_the_organ():
assert DETAIL.exists(), "le detail deporte a disparu"
detail = _norm(DETAIL.read_text(encoding="utf-8"))
for marker in ("head is stale", "diff-files is stale",
"surfaces changed or were not fully attested"):
assert marker in detail, "verbatim de l'organe absent du detail : {}".format(marker)


def test_detail_doc_marks_the_measurement_as_reported():
"""Honteete SDDD : 17/17 est un temoignage date, le mecanisme est ce qui est verifie."""
detail = _norm(DETAIL.read_text(encoding="utf-8"))
assert "rapporte par l'issue" in detail
assert "non re-mesure" in detail


def test_detail_doc_defers_to_the_rule_for_the_order():
"""#17289 : le detail explique le POURQUOI, il ne rejoue pas l'ordre."""
detail = _norm(DETAIL.read_text(encoding="utf-8"))
assert "la regle fait foi" in detail
assert "il n'est pas recopie ici" in detail or "pas recopie ici" in detail


def test_organ_still_emits_the_three_refusal_shapes():
"""Si l'organe changeait, les verbatim cites par le detail deviendraient faux."""
organ = ORGAN.read_text(encoding="utf-8")
assert 'f"{key} is stale: dossier=' in organ
assert '"head is stale: dossier=' in organ
Loading