Fusion Idempotente de Section Markdown
Procédure pour maintenir une section canonique ## Titre dans un document
Markdown sous automatisation : remplacer la section lorsqu'elle existe, l'ajouter
lorsqu'elle n'existe pas, et produire une sortie identique en octets lorsqu'elle
est appliquée deux fois. La partie non évidente est l'endroit où la section
se termine — et l'erreur classique consiste à faire confiance à une
expression régulière ^## nue pour trouver cette limite.
Quand l'utiliser
- Un agent ou un script maintient une section canonique à jour dans un document
dont il n'est pas le propriétaire exclusif —
README.md,AGENTS.md,CLAUDE.md, un manuel, un préambule de journal des modifications — selon la règle "remplacer si présent, ajouter si absent". - Vous observez l'un de ces symptômes :
- La section maintenue est dupliquée après chaque exécution.
- Une fusion a tronqué la section au milieu d'un bloc de code délimité.
- Une ligne
## ...à l'intérieur d'un bloc de code a été traitée comme la section suivante. - Un sous-titre
###a mis fin prématurément à la plage de remplacement. - Le script de mise à jour ne converge jamais : la deuxième exécution modifie encore le fichier.
- Après une fusion, tout ce qui se trouve en dessous d'un certain point s'affiche comme un bloc de code géant (un délimiteur de bloc restant a rouvert un nouveau bloc).
- Vous lisez ou comptez des sections avec des motifs de style
^##, pas seulement en écrivant — l'extraction et la vérification tombent dans le même piège.
Le piège : les titres à l'intérieur des blocs de code
L'implémentation naïve considère la plage de remplacement comme "la ligne ## X jusqu'à la ligne précédant la prochaine correspondance ^##". Elle échoue de deux manières.
Piège 1 — littéraux dans les blocs de code. Les corps de sections contiennent légitimement des lignes ## ... à l'intérieur de blocs de code : un modèle de rapport, un document d'exemple, un diff cité. Un scan aveugle aux blocs de code interprète la première ligne de ce type comme la limite de la section suivante et tronque la plage :
## Notes d'automatisation
Le robot actualise cette section.
```text
## Rapport hebdomadaire <- le scan ^## naïf s'arrête ici, à l'intérieur du bloc
- points saillants :
- risques :
```
Gardez le modèle dans un bloc pour qu'il ne devienne pas un vrai titre.
## Licence <- la vraie section suivante
Remplacer jusqu'à la ligne du bloc laisse une queue obsolète. La corruption mesurée (voir le scénario de test du piège) est pire qu'une simple duplication :
- Le littéral
## Rapport hebdomadairedu bloc est laissé en dehors de tout bloc et s'affiche comme un vrai titre, ressemblant à un doublon. - Le délimiteur de fermeture de l'ancien bloc (la ligne nue
```) est également laissé, se rouvre en nouveau bloc, et engloutit tout ce qui le suit — la section## Licencesuivante cesse d'être un titre. - La fusion n'est pas idempotente : chaque exécution correspond à nouveau au titre, coupe à une ligne de bloc à nouveau, et agrandit le fichier à nouveau.
Piège 2 — sous-titres ###. Une expression régulière ^## nue correspond
également à ###, ####, et ainsi de suite. Si la section contient des
sous-titres, la plage se termine à la première ligne ### ... et les
sous-sections anciennes survivent après les nouvelles — contenu dupliqué en
dessous du point de fusion.
Les deux mêmes pièges s'appliquent à la lecture d'une section (extraction de
son contenu) et à la vérification (grep -c '^## X' peut à la fois
sur-compter les littéraux dans les blocs de code et être leurré par eux).
Limites sûres : deux méthodes
Méthode 1 — scan de titres avec conscience des régions littérales
Suivre l'état des régions littérales lors du scan des lignes, et ne traiter une ligne comme titre ou limite que lorsqu'elle est en dehors du frontmatter, des blocs de code et des blocs HTML bruts supportés :
- Un bloc de code s'ouvre sur une ligne dont les premiers caractères non espaces (au maximum 3 espaces en début de ligne) sont une série de 3+ backticks ou 3+ tildes.
- Il se ferme uniquement sur une ligne avec une série du même caractère, au moins aussi longue, sans rien d'autre sur la ligne. Un bloc de code non fermé s'étend jusqu'à la fin du document (comportement CommonMark).
- Tant qu'on est à l'intérieur d'un bloc, rien n'est un titre et rien n'est une limite.
- Avant le scan des blocs, un bloc de frontmatter en début de ligne exacte est
exclu : YAML s'ouvre avec
---et se ferme avec---ou...exact ; TOML s'ouvre et se ferme avec+++exact. Une ligne ressemblant à un titre à l'intérieur de ce bloc est un contenu de métadonnées/commentaire, pas une limite de section Markdown. Un ouvreur sans son fermeur exact échoue en échec avant toute écriture. - Au niveau supérieur, les colonnes 0–3 des
blocs HTML bruts CommonMark 0.31.2
de types 1 à 7 sont également exclus : les balises de contenu littéral
(
pre/script/style/textarea), les commentaires, les instructions de traitement, les déclarations, CDATA, les balises de bloc listées, et les lignes de balises complètes. Les types 6/7 se terminent avant une ligne vide ou à la fin du fichier ; le type 7 ne peut pas interrompre un paragraphe. La grammaire des balises et attributs reste en ASCII uniquement même sous correspondance insensible à la casse ; les caractères Unicode qui imitent la casse restent du Markdown ordinaire. L'état des blocs de code et HTML partage un seul scanner exclusif, de sorte que les délimiteurs à l'intérieur de l'autre région restent des données littérales. - Cette référence mutante est intentionnellement plus stricte que CommonMark
pour une fin de fichier ambiguë : un ouvreur reconnu de type 1–5 sans sa
condition de fin explicite échoue en échec. Un ouvreur possible de type 7
après un conteneur/contexte indenté non supporté échoue également en échec
plutôt que de deviner. La même règle couvre une possible définition de
référence de lien suivie de
===, où un analyseur partiel ne peut pas prouver si le setext a terminé le paragraphe. Les possibles étiquettes de lien sont suivies sur plusieurs lignes avec une gestion des backslashes dans les crochets ; un]non échappé sans deux-points immédiat libère l'état de définition possible. La forme simple de définition plus balise sans ligne vide reste du HTML en ligne. - La limite de section est le titre suivant de niveau 1 ou 2. La forme
populaire est
^##[^#](exclut###) ; la référence le durcit en^ {0,3}#{1,2}([ \t]|$), qui traite en plus un H1 comme limite (une limite de partie ne doit jamais être engloutie dans un remplacement), accepte l'indentation de 1 à 3 espaces autorisée par CommonMark, et applique la règle d'espace de CommonMark pour qu'une ligne de paragraphe de style##hashtagne soit pas une fausse limite.###n'est toujours pas une limite dans les deux cas.
Utilisez cette méthode lorsque le format du document n'est pas le vôtre à modifier, ou lorsque l'outil doit fonctionner sur du Markdown arbitraire.
Méthode 2 — marqueurs fixes
Encadrez la section maintenue par des lignes sentinelles uniques — les commentaires HTML sont invisibles dans le Markdown rendu :
<!-- managed-section:begin notes-automatisation -->
## Notes d'automatisation
...corps, modèles dans des blocs bienvenus...
<!-- managed-section:end notes-automatisation -->
Remplacez tout ce qui se trouve entre les lignes exactes de marqueur début/fin ; ajoutez tout le bloc marqué lorsque le marqueur de début est absent. Le scan des titres ne fait plus partie de la recherche de plage.
Utilisez cette méthode lorsque vous possédez le format du document : c'est la limite la plus robuste (elle survit aux renommages de titres et à tout contenu de corps), au prix de la plantation de marqueurs. Gardez les chaînes de marqueur uniques par section, et maintenez l'invariant de H2 unique ci-dessous même ici — les marqueurs protègent la plage, pas la structure de titres du document. Une note d'honnêteté : une copie littérale de la ligne de marqueur à l'intérieur d'un bloc de code confondrait toujours une recherche de marqueur aveugle aux blocs, alors soit gardez le texte du marqueur hors des exemples dans les blocs, soit trouvez les marqueurs avec le même scan conscient des blocs.
Invariants
Appliquez ces invariants avant et après chaque fusion ; ils rendent "remplacer ou ajouter" bien défini :
- Le bloc commence par sa propre ligne
## Titresimple — utilisez exactement un espace ASCII entre le##d'ouverture et le contenu non vide (ou exact##pour un H2 vide). Les espaces de séparation supplémentaires, les tabulations et une séquence de croisillon de fermeture CommonMark ne sont pas canoniques ici. Le titre fait partie du contenu fusionné, pas une configuration qui peut en dériver. - Exactement un titre de niveau H1/H2 à l'intérieur du bloc — son propre premier ATX (comptage conscient des régions littérales). Un bloc avec un second ATX ou un setext H1/H2 absorbera ou divisera silencieusement une section lors d'une exécution ultérieure. Rejetez les titres setext possibles avant la première écriture afin que l'ajout et le remplacement utilisent la même limite de validation. Les littéraux de titre dans les blocs de code ou HTML bruts à l'intérieur du bloc sont acceptables — ils ne comptent pas.
- Au plus une copie non ambiguë du titre dans la cible (en dehors des régions littérales). Si le document contient déjà des doublons ou un alias du H2 géré (séparateur, 1 à 3 espaces, croisillon de fermeture), arrêtez-vous et signalez au lieu de "corriger" une forme et d'en laisser une autre.
- Une forme de séparateur canonique. Stockez le bloc sans lignes vides finales ; écrivez exactement une ligne vide entre le bloc et la section suivante. La plage de lecture et la forme d'écriture convergent alors vers les mêmes octets, ce qui rend la deuxième exécution une non-opération.
- Pas de blocs de code non fermés, ni dans la cible ni dans le bloc. CommonMark étend un bloc non fermé jusqu'à la fin du document, donc dans une cible, il étend silencieusement la section jusqu'à la fin du fichier (un remplacement réécrirait toute la queue visuellement engloutie), et dans un bloc, il engloutirait ce qui suit la section fusionnée. Les deux sont des entrées mal formées : arrêtez-vous et signalez.
- Pas de titres setext à l'intérieur du bloc canonique ou de la plage
remplacée. Un soulignement
===ou---directement sous du texte de paragraphe est un vrai titre qu'un scan^#ligne par ligne ne peut pas voir comme limite. En accepter un dans un bloc nouvellement ajouté ferait échouer la prochaine exécution au lieu de converger ; en remplacer un supprimerait une limite de section. Arrêtez-vous avant d'écrire dans les deux cas (convertissez le titre en forme ATX, ou utilisez des marqueurs fixes). Voirdocs/setext-block-heading-contract.md. - Pas de frontmatter d'ouverture non fermé. Lorsque la première ligne est
exact
---(YAML) ou+++(TOML), ignorez ses lignes ressemblant à des titres uniquement jusqu'au premier fermeur exact correspondant. S'il n'y a pas de fermeur, arrêtez-vous au lieu de deviner si l'ouvreur était une métadonnée ou une rupture thématique Markdown ordinaire. - Pas de HTML brut de fin explicite ambigu. CommonMark laisse les types 1–5 s'étendre jusqu'à la fin du fichier lorsque leur condition de fin est absente, mais ajouter un H2 là le cacherait dans le HTML brut et briserait la convergence. Exigez la condition de fin explicite à la fois dans la cible et dans le bloc. Pour le type 7, préservez sa règle de non interruption de paragraphe et échouez en échec lorsqu'un conteneur/contexte indenté non supporté ou un contexte de référence possible plus setext, y compris une étiquette de lien multi-lignes, rend cette décision ambiguë.
- Espaces blancs ASCII uniquement dans le bloc. Les lignes vides, les
espaces de début/fin de titre ATX et les séquences de fermeture, le
contexte setext et les fermetures de blocs utilisent espace/tabulation
ASCII exactement comme spécifié par CommonMark 0.31.2. Les NBSP, EM SPACE,
saut de page, tabulation verticale et autres espaces Unicode restent du
contenu au lieu d'être silencieusement supprimés. Voir
docs/commonmark-ascii-whitespace-contract.md.
Recette de vérification
Exécutez les trois vérifications après une fusion ; ensemble, elles détectent les bugs de limite, les sections dupliquées et les modifications collatérales :
-
Appliquer-deux-fois-diff-zéro. Appliquez la même fusion deux fois ; après la deuxième exécution,
git diffdoit être vide (ou comparez les octets avant/après). Pour une section située à la fin du fichier, une vérification équivalente est que le bloc est égal à la section de queue entière du fichier.python scripts/merge_section.py cible.md section.md python scripts/merge_section.py cible.md section.md # deuxième exécution git diff --exit-code cible.md # après avoir commité la première exécution : videLe mode
--checkde l'implémentation de référence encode la même idée avec des codes de sortie de style diff (0 = canonique, 1 = une fusion modifierait le fichier), que CI peut appeler directement. -
Nombre d'occurrences du titre = 1. Comptez les lignes égales au titre avec un scan conscient des blocs. Un
grep -c '^## Notes d\'automatisation$'nu est sujet au même piège que cette compétence aborde — acceptable uniquement lorsque vous savez que le corps ne contient aucune copie dans un bloc de la ligne de titre exacte. -
git diff --stattouche exactement un fichier — la cible. Tout autre chose signifie que l'automatisation a écrit là où elle n'aurait pas dû.
Implémentation de référence
scripts/merge_section.py implémente la Méthode 1
plus chaque invariant ci-dessus en Python 3 sans dépendances (bibliothèque
standard uniquement). Python a été choisi plutôt qu'un shell pour une seule
raison qui compte ici : le contrôle explicite au niveau des octets des sauts
de ligne et du BOM rend l'application-deux-fois-diff-zéro prouvable, alors
que les pipelines de texte shell ont tendance à normaliser les fins de ligne
en coulisse. L'algorithme se porte directement dans n'importe quel langage.
python scripts/merge_section.py CIBLE.md SECTION.md # fusion en place
python scripts/merge_section.py CIBLE.md SECTION.md --check # vérification de dérive
SECTION.md est le bloc canonique : première ligne est exact ## Titre, avec un
espace ASCII avant le contenu non vide, ou exact ## pour un H2 vide.
Le style LF/CRLF de la cible et le BOM UTF-8 sont préservés ; une cible
manquante est créée (ajout dans un document vide). Les octets modifiés sont
vidés dans un fichier temporaire exclusif du même répertoire et installés avec
un seul remplacement atomique. Le fichier temporaire commence en mode POSIX
0600 ou une DACL Windows protégée SYSTEM/Propriétaire-seulement, et son
identité, ses octets, ses métadonnées et sa DACL Windows sont revérifiés avant
le commit. Les attributs étendus POSIX existants (propriétaire/groupe/mode/limite) sont
préservés. Pour une cible Windows existante, ReplaceFileW transmet sa DACL
documentée, ses attributs de fichier et ses flux nommés ; la préservation
exacte du propriétaire/groupe/SACL n'est pas garantie. Les cibles manquantes
conservent les permissions privées.
La cible et le bloc canonique sont lus comme des instantanés de fichiers
ordinaires sans suivi de liens symboliques. Les entrées de lien symbolique,
non régulières et multi-liens durs sont refusées car le remplacement
changerait leur sémantique ; les points de réanalyse Windows et les fichiers
Windows chiffrés par EFS sont refusés avant la lecture du contenu.
L'identité, les métadonnées et les octets de la cible sont revérifiés juste
avant le commit. Les modifications achevées avant cette vérification sont
détectées, mais la vérification et le remplacement sont des opérations
séparées ; sérialisez en externe chaque écrivain lorsqu'une cible existante ne
doit pas subir de perte de mise à jour. La création de cible manquante
utilise un commit sans remplacement et n'écrase jamais une création
concurrente. Sous Windows, ReplaceFileW utilise un
…
[tronqué pour la traduction]