Claude commited on
Commit
52ddacb
·
unverified ·
1 Parent(s): edcb314

test(architecture): doc governance guards lock Phase 1 posture

Browse files

Nouveau test tests/architecture/test_doc_governance.py qui verrouille
structurellement la posture documentaire visée par Phase 1 :

1. **test_root_md_budget** : ≤ 9 fichiers .md à la racine (cible 7,
9 toléré tant que CONTRIBUTING.en.md et SECURITY.en.md restent
à la racine pour la convention GitHub).
2. **test_no_active_doc_exceeds_line_budget** : aucun fichier
actif > 600 lignes sauf allowlist explicite (specification.md
et CHANGELOG.md, justifiés en revue).
3. **test_all_archive_files_have_header_or_are_indexed** : tout
fichier sous docs/archive/ doit soit avoir un bandeau « Archived »
en tête, soit être listé dans docs/archive/README.md. Empêche
le retour silencieux d'archives non-signalées.
4. **test_mkdocs_nav_excludes_archive_subdirs** : la nav mkdocs ne
référence que docs/archive/README.md comme point d'entrée, jamais
directement les sous-dossiers. Sans ça, les fichiers archivés
réapparaissent dans la nav utilisateur.
5. **test_no_active_doc_contains_sprint_narrative** : aucun fichier
actif ne contient de mentions narratives « Sprint XX » en prose
au-delà du baseline. Le scan exclut les code-spans et les liens
markdown (références techniques autorisées) pour ne capturer
que la vraie narration de chantier.
6. **test_active_narrative_baseline_decreases** : ratchet
strictement décroissant — toute PR de nettoyage doit baisser
ACTIVE_NARRATIVE_BASELINE dans le même commit.

Le test #5 a immédiatement révélé 88 mentions narratives sprint
dans la doc active (cible Phase 2). Posées en baseline pour
verrouiller la non-augmentation ; le nettoyage progressif baissera
le compteur vers 0.

Sortie Phase 1 :
- racine : 11 → 9 fichiers .md
- ratchet test_doc_paths : 164 → 6 (-158)
- docs/archive : consolidation 3 zones historiques → 1
- politique linguistique : 7 paires .en.md → 3 (alignement strict)
- CHANGELOG actif : 4343 → 567 lignes
- 6 nouveaux garde-fous structurels (test_doc_governance) en plus
des 2 garde-fous Phase 1 ajoutés en D1 (test_index_links_resolve)
et en D2 (test_no_en_md_outside_translation_pairs).

tests/architecture/test_doc_governance.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fous structurels de la documentation (Phase 1 D7).
2
+
3
+ Ces tests verrouillent la posture documentaire visée après Phase 1 :
4
+
5
+ - **Racine sobre** : ≤ 9 fichiers .md à la racine du repo.
6
+ - **Fichiers actifs courts** : aucun fichier actif > 600 lignes, sauf
7
+ allowlist explicite (CHANGELOG, spécification produit).
8
+ - **Archive explicite** : tout fichier sous ``docs/archive/`` doit
9
+ être signalé comme archivé (header « Archived document » dans le
10
+ fichier ou indexation depuis ``docs/archive/README.md``).
11
+ - **Nav mkdocs sobre** : la nav ne référence pas directement les
12
+ sous-dossiers d'archive — seule l'entrée
13
+ ``docs/archive/README.md`` est autorisée.
14
+ - **Pas de narration sprint dans la doc active** : les fichiers
15
+ actifs ne contiennent pas de phrases narratives type « Sprint
16
+ S2.1 — rééquilibrage » qui décrivent l'histoire du chantier
17
+ plutôt que le contrat actuel. Les chemins/identifiants
18
+ techniques (ex. ``rewrite-status-s46.md`` dans un lien) restent
19
+ autorisés — c'est de la référence, pas de la narration.
20
+
21
+ Toute violation = échec CI. Le but n'est pas d'être maximaliste ;
22
+ c'est d'éviter la dérive lente vers le mode « chantier en cours »
23
+ qui avait amené le repo à 70+ fichiers .md.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from pathlib import Path
30
+
31
+ import pytest
32
+ import yaml
33
+
34
+ REPO_ROOT = Path(__file__).resolve().parents[2]
35
+
36
+ # ─────────────────────────────────────────────────────────────────────
37
+ # Constantes — politique chiffrée
38
+ # ─────────────────────────────────────────────────────────────────────
39
+
40
+ #: Budget de fichiers .md à la racine. Cible 7 ; 9 toléré pendant
41
+ #: la phase de transition (CONTRIBUTING.en.md et SECURITY.en.md sont
42
+ #: gardés à la racine par convention GitHub pour la visibilité
43
+ #: institutionnelle — sinon le badge "code of conduct" et autres
44
+ #: index communautaires ne les trouvent pas).
45
+ ROOT_MD_BUDGET = 9
46
+
47
+ #: Plafond de lignes pour un fichier de doc actif. Au-delà, le
48
+ #: fichier devrait être découpé ou élagué. L'allowlist explicite
49
+ #: ci-dessous contient les exceptions justifiées (spec produit,
50
+ #: changelog).
51
+ ACTIVE_DOC_LINE_BUDGET = 600
52
+
53
+ #: Fichiers de doc autorisés à dépasser le budget. Toute addition
54
+ #: doit être justifiée en revue — ce n'est pas une zone tampon, c'est
55
+ #: un registre explicite des exceptions.
56
+ ACTIVE_DOC_LINE_BUDGET_ALLOWLIST: frozenset[str] = frozenset({
57
+ # Spec produit : grandit naturellement avec les fonctionnalités ;
58
+ # déjà découpée en sections, le tout en un fichier reste utile
59
+ # comme contrat unique consultable d'un coup.
60
+ "docs/reference/specification.md",
61
+ # CHANGELOG actif : période v2.0 et après. L'historique pré-v2.0
62
+ # vit dans docs/archive/changelog-pre-v2.md.
63
+ "CHANGELOG.md",
64
+ })
65
+
66
+ #: Préfixes des chemins considérés comme "actifs" (non archivés).
67
+ ACTIVE_DOC_AREAS: tuple[str, ...] = (
68
+ "docs/",
69
+ )
70
+
71
+ #: Préfixes EXCLUS de la zone active (= archives, hors périmètre des
72
+ #: garde-fous structurels).
73
+ ARCHIVE_PATH_PREFIXES: tuple[str, ...] = (
74
+ "docs/archive/",
75
+ )
76
+
77
+ #: Header obligatoire dans chaque fichier d'archive (ou alternative :
78
+ #: indexation depuis docs/archive/README.md).
79
+ ARCHIVE_HEADER_MARKERS: tuple[str, ...] = (
80
+ "Archived document",
81
+ "Archived",
82
+ "**Archived**",
83
+ "Archive historique",
84
+ )
85
+
86
+ #: Patterns narratifs interdits dans la doc active. Ces motifs
87
+ #: décrivent l'histoire du chantier ; ils n'ont pas leur place dans
88
+ #: une doc qui décrit le contrat actuel. Les références techniques
89
+ #: (chemins de fichiers archivés, identifiants de releases) sont
90
+ #: gérées par une exclusion contextuelle (mot dans un lien
91
+ #: markdown ou backtick — laissé passer).
92
+ FORBIDDEN_NARRATIVE_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
93
+ # « Sprint XX » en prose (pas dans un code-span, pas dans un lien)
94
+ (
95
+ "Sprint XX en prose",
96
+ re.compile(
97
+ r"(?<![`/\-])Sprint\s+[A-Z]?\d+(?:\.\d+)*(?![`/\.\-\w])",
98
+ ),
99
+ ),
100
+ # « handover » dans le texte (les liens vers session-handover.md
101
+ # sont OK car le mot est entre slashes ou backticks)
102
+ (
103
+ "narration handover",
104
+ re.compile(
105
+ r"(?<![`/\-])\bhandover\b(?![`/\-])",
106
+ re.IGNORECASE,
107
+ ),
108
+ ),
109
+ )
110
+
111
+
112
+ # ─────────────────────────────────────────────────────────────────────
113
+ # Helpers
114
+ # ───────────────────────────────────────────────────────────────────��─
115
+
116
+
117
+ def _all_md_files() -> list[Path]:
118
+ return sorted(REPO_ROOT.glob("*.md")) + sorted(
119
+ REPO_ROOT.glob("docs/**/*.md")
120
+ )
121
+
122
+
123
+ def _is_archive(path: Path) -> bool:
124
+ rel = path.relative_to(REPO_ROOT).as_posix()
125
+ return any(rel.startswith(p) for p in ARCHIVE_PATH_PREFIXES)
126
+
127
+
128
+ def _is_active_doc(path: Path) -> bool:
129
+ """Une doc « active » = sous ``docs/`` mais pas sous archive,
130
+ ou à la racine du repo."""
131
+ rel = path.relative_to(REPO_ROOT).as_posix()
132
+ if rel.startswith("docs/"):
133
+ return not any(rel.startswith(p) for p in ARCHIVE_PATH_PREFIXES)
134
+ # Racine : un seul niveau de profondeur, donc on regarde si ça
135
+ # ressemble à un .md racine.
136
+ return "/" not in rel and rel.endswith(".md")
137
+
138
+
139
+ def _strip_code_and_links(text: str) -> str:
140
+ """Retire les blocs code, les inline-code et les liens markdown
141
+ avant de chercher des motifs narratifs. Les références techniques
142
+ (``Sprint 78`` dans un code-span ou un nom de fichier) ne sont
143
+ pas de la narration."""
144
+ # Blocs de code triple-backtick
145
+ text = re.sub(r"```[\s\S]*?```", "", text)
146
+ # Inline code single-backtick
147
+ text = re.sub(r"`[^`]*`", "", text)
148
+ # Liens markdown ``[label](url)`` : on garde le label, on retire
149
+ # l'URL où les noms de fichiers archivés vivent.
150
+ text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
151
+ return text
152
+
153
+
154
+ # ─────────────────────────────────────────────────────────────────────
155
+ # 1. Budget racine
156
+ # ─────────────────────────────────────────────────────────────────────
157
+
158
+
159
+ def test_root_md_budget() -> None:
160
+ """La racine du repo doit contenir ≤ ROOT_MD_BUDGET fichiers .md.
161
+
162
+ Cibler une racine sobre : un nouveau contributeur voit en quelques
163
+ lignes ce qu'est le projet. Tout fichier de référence ou
164
+ d'opération vit dans ``docs/``."""
165
+ root_mds = sorted(REPO_ROOT.glob("*.md"))
166
+ rel = [p.name for p in root_mds]
167
+ assert len(root_mds) <= ROOT_MD_BUDGET, (
168
+ f"Racine contient {len(root_mds)} fichiers .md (budget = "
169
+ f"{ROOT_MD_BUDGET}) : {rel}.\n"
170
+ "Déplacer les fichiers non-institutionnels vers docs/."
171
+ )
172
+
173
+
174
+ # ─────────────────────────────────────────────────────────────────────
175
+ # 2. Budget de lignes par fichier actif
176
+ # ─────────────────────────────────────────────────────────────────────
177
+
178
+
179
+ def test_no_active_doc_exceeds_line_budget() -> None:
180
+ """Aucun fichier de doc active ne doit dépasser
181
+ ACTIVE_DOC_LINE_BUDGET lignes, sauf allowlist explicite."""
182
+ offenders: list[str] = []
183
+ for path in _all_md_files():
184
+ rel = path.relative_to(REPO_ROOT).as_posix()
185
+ if not _is_active_doc(path):
186
+ continue
187
+ if rel in ACTIVE_DOC_LINE_BUDGET_ALLOWLIST:
188
+ continue
189
+ n_lines = len(path.read_text(encoding="utf-8").splitlines())
190
+ if n_lines > ACTIVE_DOC_LINE_BUDGET:
191
+ offenders.append(f" {rel} : {n_lines} lignes")
192
+
193
+ assert not offenders, (
194
+ f"Fichiers actifs au-dessus du budget "
195
+ f"{ACTIVE_DOC_LINE_BUDGET} lignes :\n"
196
+ + "\n".join(offenders)
197
+ + "\n\n→ Soit découper/élaguer, soit ajouter à "
198
+ "ACTIVE_DOC_LINE_BUDGET_ALLOWLIST avec justification."
199
+ )
200
+
201
+
202
+ # ─────────────────────────────────────────────────────────────────────
203
+ # 3. Fichiers d'archive : header ou indexation
204
+ # ─────────────────────────────────────────────────────────────────────
205
+
206
+
207
+ def _archive_index_text() -> str:
208
+ """Contenu de docs/archive/README.md, qui est l'index des
209
+ archives. Un fichier archivé sans header peut être « couvert »
210
+ par une mention dans cet index."""
211
+ index = REPO_ROOT / "docs" / "archive" / "README.md"
212
+ if not index.exists():
213
+ return ""
214
+ return index.read_text(encoding="utf-8")
215
+
216
+
217
+ def test_all_archive_files_have_header_or_are_indexed() -> None:
218
+ """Tout fichier sous ``docs/archive/`` doit soit contenir un
219
+ marqueur « Archived » en tête (lecteur direct), soit être
220
+ référencé depuis ``docs/archive/README.md`` (lecteur via index).
221
+
222
+ Sans ce test, un fichier déplacé en archive sans bandeau pourrait
223
+ être lu comme de la doc active."""
224
+ archive_dir = REPO_ROOT / "docs" / "archive"
225
+ if not archive_dir.exists():
226
+ pytest.skip("docs/archive/ absent")
227
+
228
+ index_text = _archive_index_text()
229
+ offenders: list[str] = []
230
+
231
+ for path in sorted(archive_dir.rglob("*.md")):
232
+ if path.name == "README.md":
233
+ continue # l'index lui-même
234
+ # Lit les 30 premières lignes (= la zone "tête de fichier")
235
+ head = "\n".join(
236
+ path.read_text(encoding="utf-8").splitlines()[:30]
237
+ )
238
+ has_header = any(m in head for m in ARCHIVE_HEADER_MARKERS)
239
+ rel = path.relative_to(REPO_ROOT).as_posix()
240
+ # « indexé » = chemin relatif au répertoire archive cité dans
241
+ # README.md de l'archive
242
+ rel_to_archive = path.relative_to(archive_dir).as_posix()
243
+ is_indexed = (
244
+ rel_to_archive in index_text
245
+ or path.name in index_text
246
+ or rel_to_archive.rsplit("/", 1)[0] + "/" in index_text
247
+ )
248
+ if not (has_header or is_indexed):
249
+ offenders.append(f" {rel}")
250
+
251
+ assert not offenders, (
252
+ f"Fichiers d'archive sans bandeau « Archived » ni "
253
+ f"indexation depuis docs/archive/README.md :\n"
254
+ + "\n".join(offenders)
255
+ + "\n\n→ Ajouter ``> **Archived document.**`` en tête du "
256
+ "fichier OU ajouter une mention explicite dans "
257
+ "docs/archive/README.md."
258
+ )
259
+
260
+
261
+ # ─────────────────────────────────────────────────────────────────────
262
+ # 4. mkdocs nav exclut les sous-dossiers d'archive
263
+ # ─────────────────────────────────────────────────────────────────────
264
+
265
+
266
+ def _mkdocs_nav() -> list:
267
+ mkdocs = REPO_ROOT / "mkdocs.yml"
268
+ if not mkdocs.exists():
269
+ return []
270
+ # mkdocs utilise des balises spéciales — safe_load suffit pour
271
+ # lire la structure de nav. En cas de balises personnalisées,
272
+ # on tombe sur un parse_error explicite.
273
+ try:
274
+ doc = yaml.safe_load(mkdocs.read_text(encoding="utf-8"))
275
+ except yaml.YAMLError as e:
276
+ pytest.fail(f"mkdocs.yml YAML invalide : {e}")
277
+ return doc.get("nav") or []
278
+
279
+
280
+ def _flatten_nav_paths(nav: list, acc: list[str] | None = None) -> list[str]:
281
+ """Aplatit la nav mkdocs en liste de chemins de fichiers
282
+ référencés."""
283
+ if acc is None:
284
+ acc = []
285
+ for entry in nav:
286
+ if isinstance(entry, str):
287
+ acc.append(entry)
288
+ elif isinstance(entry, dict):
289
+ for v in entry.values():
290
+ if isinstance(v, str):
291
+ acc.append(v)
292
+ elif isinstance(v, list):
293
+ _flatten_nav_paths(v, acc)
294
+ return acc
295
+
296
+
297
+ def test_mkdocs_nav_excludes_archive_subdirs() -> None:
298
+ """La nav mkdocs ne doit référencer aucun fichier sous
299
+ ``docs/archive/`` SAUF ``docs/archive/README.md`` (l'index).
300
+
301
+ Sans cette discipline, les fichiers archivés réapparaissent dans
302
+ la navigation utilisateur — exactement le problème que l'archive
303
+ devait résoudre."""
304
+ nav_paths = _flatten_nav_paths(_mkdocs_nav())
305
+ offenders: list[str] = []
306
+ for p in nav_paths:
307
+ if p.startswith("archive/") and p != "archive/README.md":
308
+ offenders.append(p)
309
+ # Couvre aussi les chemins fully-qualified si présents
310
+ if "docs/archive/" in p:
311
+ if not p.endswith("docs/archive/README.md"):
312
+ offenders.append(p)
313
+
314
+ assert not offenders, (
315
+ "mkdocs.yml référence des fichiers d'archive dans la nav "
316
+ f"active : {offenders}.\n"
317
+ "→ Ne garder que ``archive/README.md`` (point d'entrée vers "
318
+ "les archives)."
319
+ )
320
+
321
+
322
+ # ─────────────────────────────────────────────────────────────────────
323
+ # 5. Pas de narration sprint dans la doc active
324
+ # ─────────────────────────────────────────────────────────────────────
325
+
326
+
327
+ #: Nombre de mentions narratives sprint dans la doc active au
328
+ #: moment de la mise en place du garde-fou (Phase 1 D7, juin 2026).
329
+ #: Le test verrouille que ce compteur ne PEUT plus augmenter ; toute
330
+ #: PR de nettoyage qui en retire doit baisser ce baseline du même
331
+ #: montant (ratchet strictement décroissant).
332
+ #:
333
+ #: Ces 88 mentions sont concentrées dans :
334
+ #: - docs/reference/views.md (15) : narration historique des sprints
335
+ #: de définition des vues — à reformuler en contrat actuel ;
336
+ #: - docs/reference/{alto,text,comparing}-view.md (5 chacun) ;
337
+ #: - docs/operations/{deployment,accessibility,release-process}.md ;
338
+ #: - docs/explanation/architecture.md (4 refs « Sprint S1.x ») ;
339
+ #: - quelques fichiers à la racine (README, GOVERNANCE, SECURITY).
340
+ #:
341
+ #: Cible : 0 (Phase 2 — convergence narrative, lot D9 à prévoir).
342
+ ACTIVE_NARRATIVE_BASELINE = 88
343
+
344
+
345
+ def test_no_active_doc_contains_sprint_narrative() -> None:
346
+ """La doc active ne doit pas contenir de narration de chantier
347
+ type « Sprint A2 — refonte X » en prose.
348
+
349
+ Les références techniques (chemin de fichier archivé, identifiant
350
+ de release) restent autorisées car elles sont dans des liens ou
351
+ des code-spans, retirés du texte avant le scan."""
352
+ offenders: list[tuple[str, str]] = []
353
+ for path in _all_md_files():
354
+ if not _is_active_doc(path):
355
+ continue
356
+ rel = path.relative_to(REPO_ROOT).as_posix()
357
+ # On exclut le CHANGELOG actif et la spec : ils peuvent citer
358
+ # des sprints dans leur historique structuré (sections
359
+ # taggées) sans que ce soit de la narration insidieuse.
360
+ if rel in ACTIVE_DOC_LINE_BUDGET_ALLOWLIST:
361
+ continue
362
+ # On exclut aussi le fichier de gouvernance documentaire
363
+ # (CLAUDE.md) qui décrit le code et peut référencer des
364
+ # sprints comme repères historiques.
365
+ if rel == "CLAUDE.md":
366
+ continue
367
+ text = _strip_code_and_links(path.read_text(encoding="utf-8"))
368
+ for label, pattern in FORBIDDEN_NARRATIVE_PATTERNS:
369
+ for match in pattern.finditer(text):
370
+ offenders.append((rel, f"{label} : « {match.group(0)} »"))
371
+
372
+ # Filtrer les doublons exacts (un même match listé une seule fois)
373
+ offenders = sorted(set(offenders))
374
+
375
+ assert len(offenders) <= ACTIVE_NARRATIVE_BASELINE, (
376
+ f"Doc active contient {len(offenders)} mentions narratives "
377
+ f"de chantier (baseline ratchet = {ACTIVE_NARRATIVE_BASELINE}) :\n"
378
+ + "\n".join(f" {f} : {m}" for f, m in offenders[:30])
379
+ + ("\n …" if len(offenders) > 30 else "")
380
+ + "\n\n→ Reformuler les phrases qui décrivent l'histoire du "
381
+ "chantier en phrases qui décrivent le contrat actuel. Ou "
382
+ "baisser ACTIVE_NARRATIVE_BASELINE si on accepte cette dette."
383
+ )
384
+
385
+
386
+ def _count_active_narrative_mentions() -> int:
387
+ """Compte les mentions narratives dans la doc active (utilisé par
388
+ les deux tests de ratchet pour garantir la même métrique)."""
389
+ offenders: list[tuple[str, str]] = []
390
+ for path in _all_md_files():
391
+ if not _is_active_doc(path):
392
+ continue
393
+ rel = path.relative_to(REPO_ROOT).as_posix()
394
+ if rel in ACTIVE_DOC_LINE_BUDGET_ALLOWLIST or rel == "CLAUDE.md":
395
+ continue
396
+ text = _strip_code_and_links(path.read_text(encoding="utf-8"))
397
+ for label, pattern in FORBIDDEN_NARRATIVE_PATTERNS:
398
+ for match in pattern.finditer(text):
399
+ offenders.append((rel, f"{label} : {match.group(0)}"))
400
+ return len(sorted(set(offenders)))
401
+
402
+
403
+ def test_active_narrative_baseline_decreases() -> None:
404
+ """Ratchet : si le compteur descend en-dessous du baseline, il
405
+ faut mettre à jour la constante dans le même commit pour
406
+ verrouiller le gain. Pattern classique des tests d'architecture
407
+ (cf. test_doc_paths.py)."""
408
+ actual = _count_active_narrative_mentions()
409
+ assert actual >= ACTIVE_NARRATIVE_BASELINE, (
410
+ f"Excellent : {actual} mentions narratives vs "
411
+ f"baseline {ACTIVE_NARRATIVE_BASELINE}.\n"
412
+ f"Mets à jour ACTIVE_NARRATIVE_BASELINE = {actual} "
413
+ "pour verrouiller le gain."
414
+ )