Markdown Idempotenter Abschnitts-Merge (Idempotent Section Merge)
Verfahren zur Pflege eines kanonischen ## Überschrift-Abschnitts in einem Markdown-Dokument unter Automatisierung: Ersetzen des Abschnitts, wenn er existiert, Anhängen, wenn er nicht existiert, und Erzeugen byte-identischer Ausgabe bei zweimaliger Anwendung. Der nicht offensichtliche Teil ist, wo der Abschnitt endet – und der klassische Fehler ist, einem bloßen ^##-Regex zu vertrauen, um diese Grenze zu finden.
Wann Verwenden
- Ein Agent oder ein Skript hält einen kanonischen Abschnitt in einem Dokument aktuell, das es nicht vollständig besitzt –
README.md,AGENTS.md,CLAUDE.md, ein Handbuch, ein Changelog-Präambel – mit der Regel „ersetzen, falls vorhanden, anhängen, falls fehlend“. - Sie sehen eines dieser Symptome:
- Der gepflegte Abschnitt wird nach jedem Durchlauf dupliziert.
- Ein Merge hat den Abschnitt mitten in einem umschlossenen Code-Block abgeschnitten.
- Eine
## ...-Zeile innerhalb eines Code-Blocks wurde als nächster Abschnitt behandelt. - Eine
###-Unterüberschrift hat den Ersetzungsbereich vorzeitig beendet. - Der Aktualisierer konvergiert nie: Der zweite Durchlauf ändert die Datei immer noch.
- Nach einem Merge wird alles unterhalb eines bestimmten Punktes als ein riesiger Code-Block gerendert (ein übrig gebliebenes Fence-Trennzeichen, das als neuer Fence wieder geöffnet wird).
- Sie lesen oder zählen Abschnitte mit
^##-artigen Mustern, nicht nur schreiben – Extraktion und Überprüfung geraten in die gleiche Falle.
Die Falle: Überschriften Innerhalb von Code-Blöcken
Die naive Implementierung nimmt den Ersetzungsbereich als „die ## X-Zeile bis zur Zeile vor dem nächsten ^##-Treffer“. Sie bricht auf zwei Arten.
Falle 1 – Umschlossene Literale. Abschnittskörper enthalten legitimerweise ## ...-Zeilen innerhalb von umschlossenen Code-Blöcken: eine Berichtsvorlage, ein Beispieldokument, ein zitierter Diff. Ein fence-blinder Scan interpretiert die erste solche Zeile fälschlicherweise als die nächste Abschnittsgrenze und schneidet den Bereich dort ab:
## Automatisierungsnotizen
Der Bot aktualisiert diesen Abschnitt.
```text
## Wochenbericht <- naiver ^##-Scan stoppt hier, innerhalb des Fences
- Highlights:
- Risiken:
```
Halten Sie die Vorlage umschlossen, damit sie keine echte Überschrift wird.
## Lizenz <- der echte nächste Abschnitt
Das Ersetzen bis zur umschlossenen Zeile hinterlässt ein veraltetes Ende. Die gemessene Korruption (siehe die getestete Fallen-Vorrichtung) ist schlimmer als ein einfaches Duplikat:
- Das umschlossene Literal
## Wochenberichtbleibt außerhalb jedes Fences zurück und wird als echte, duplikatähnliche Überschrift gerendert. - Das schließende Fence-Trennzeichen des alten Blocks (die bloße
```-Zeile) bleibt ebenfalls zurück, öffnet sich als neuer Fence erneut und verschluckt alles danach – der folgende Abschnitt## Lizenzhört auf, eine Überschrift zu sein. - Der Merge ist nicht idempotent: Jeder Durchlauf findet die Überschrift erneut, schneidet erneut an einer umschlossenen Zeile ab und vergrößert die Datei erneut.
Falle 2 – ###-Unterüberschriften. Ein bloßes ^##-Regex stimmt auch mit ###, #### usw. überein. Wenn der Abschnitt Unterüberschriften enthält, endet der Bereich bei der ersten ### ...-Zeile und die alten Unterabschnitte überleben nach den neuen – duplizierter Inhalt unterhalb des Merge-Punkts.
Die gleichen zwei Fallen gelten für das Lesen eines Abschnitts (Extrahieren seines Inhalts) und für das Überprüfen eines Abschnitts (grep -c '^## X' kann sowohl umschlossene Literale überzählen als auch von ihnen getäuscht werden).
Sichere Grenzen: Zwei Methoden
Methode 1 – literal-regions-bewusster Überschriften-Scan
Verfolgen Sie den Status der literal-Region beim Scannen von Zeilen und behandeln Sie eine Zeile nur dann als Überschrift oder Grenze, wenn sie sich außerhalb von Frontmatter, Fences und unterstützten rohen HTML-Blöcken befindet:
- Ein Fence öffnet sich in einer Zeile, deren erste Nicht-Leerzeichen (maximal 3 führende Leerzeichen) eine Folge von 3+ Backticks oder 3+ Tilden sind.
- Er schließt sich nur in einer Zeile mit einer Folge des gleichen Zeichens, mindestens so lang, und nichts anderem in der Zeile. Ein nicht geschlossener Fence läuft bis zum Ende des Dokuments (CommonMark-Verhalten).
- Während innerhalb eines Fences ist nichts eine Überschrift und nichts eine Grenze.
- Vor dem Fence-Scan wird ein führender exakter Frontmatter-Block ausgeschlossen: YAML öffnet mit
---und schließt mit exaktem---oder...; TOML öffnet und schließt mit exaktem+++. Eine überschriftähnliche Zeile innerhalb dieses Blocks ist Metadaten-/Kommentarinhalt, keine Markdown-Abschnittsgrenze. Ein Öffner ohne seinen exakten Schließer schlägt vor jedem Schreiben fehl. - Auf oberster Ebene werden Spalte 0–3
CommonMark 0.31.2 rohe HTML-Blöcke
der Typen 1–7 ebenfalls ausgeschlossen: Literal-Inhalts-Tags
(
pre/script/style/textarea), Kommentare, Verarbeitungsanweisungen, Deklarationen, CDATA, aufgelistete Block-Tags und vollständige Tag-only-Zeilen. Typen 6/7 enden vor einer Leerzeile oder bei EOF; Typ 7 kann einen Absatz nicht unterbrechen. Tag- und Attribut-Grammatik bleibt ASCII-only, selbst bei case-insensitiver Übereinstimmung; Unicode-Case-Fold-Ähnlichkeiten sind gewöhnliches Markdown. Fence- und HTML-Status teilen einen exklusiven Scanner, sodass Trennzeichen innerhalb der anderen Region Literaldaten bleiben. - Diese mutierende Referenz ist absichtlich strenger als CommonMark bei einem
mehrdeutigen EOF: Ein erkannter Typ-1–5-Öffner ohne seine explizite Endbedingung
schlägt fehl. Ein möglicher Typ-7-Öffner nach einem nicht unterstützten
Container/eingerückten Kontext schlägt ebenfalls fehl, anstatt zu raten. Die gleiche Regel
deckt eine mögliche Link-Referenz-Definition ab, gefolgt von
===, wo ein partieller Parser nicht beweisen kann, ob der Setext den Absatz beendet hat. Mögliche Link-Labels werden über Zeilen hinweg mit Escape-bewusster Klammerbehandlung verfolgt; ein nicht escapetes]ohne einen unmittelbaren Doppelpunkt gibt den möglichen Definitionszustand frei. Die einfache no-blank-Definitions-plus-Tag-Form bleibt Inline-HTML. - Die Abschnittsgrenze ist die nächste Überschrift der Stufe 1 oder 2. Die
Volksform ist
^##[^#](schließt###aus); die Referenz härtet sie zu^ {0,3}#{1,2}([ \t]|$)aus, die zusätzlich eine H1 als Grenze behandelt (eine Teilgrenze darf niemals in eine Ersetzung verschluckt werden), den von CommonMark erlaubten 1–3-Leerzeichen-Einzug akzeptiert und CommonMarks Leerzeichenregel anwendet, sodass eine##hashtag-artige Absatzzeile keine falsche Grenze ist.###ist immer noch keine Grenze.
Verwenden Sie diese Methode, wenn das Dokumentformat nicht Ihnen gehört, oder wenn das Tool auf beliebigem Markdown arbeiten muss.
Methode 2 – fixe Markierungen
Umgeben Sie den gepflegten Abschnitt mit eindeutigen Sentinel-Zeilen – HTML-Kommentare sind im gerenderten Markdown unsichtbar:
<!-- verwalteter-abschnitt:anfang automatisierungsnotizen -->
## Automatisierungsnotizen
...Textkörper, umschlossene Vorlagen willkommen...
<!-- verwalteter-abschnitt:ende automatisierungsnotizen -->
Ersetzen Sie alles zwischen den exakten Anfang/Ende-Markierungszeilen; hängen Sie den gesamten markierten Block an, wenn die Anfangsmarkierung fehlt. Überschriften-Scans sind nicht mehr Teil der Bereichsfindung.
Verwenden Sie diese Methode, wenn Sie das Dokumentformat besitzen: Sie ist die robusteste Grenze (sie überlebt Überschriftenumbenennungen und jeden Textkörperinhalt), auf Kosten des Setzens von Markierungen. Halten Sie Markierungsstrings pro Abschnitt eindeutig und bewahren Sie auch hier die einzelne-H2-Invariante – Markierungen schützen den Bereich, nicht die Überschriftenstruktur des Dokuments. Ein Hinweis zur Ehrlichkeit: Eine wörtliche Kopie der Markierungszeile innerhalb eines Code-Blocks würde immer noch eine fence-blinde Markierungssuche verwirren. Halten Sie daher Markierungstext aus umschlossenen Beispielen heraus oder finden Sie Markierungen mit demselben fence-bewussten Scan.
Invarianten
Erzwingen Sie diese vor und nach jedem Merge; sie machen „Ersetzen oder Anhängen“ wohldefiniert:
- Der Block beginnt mit seiner eigenen einfachen
## Überschrift-Zeile – verwenden Sie genau ein ASCII-Leerzeichen zwischen dem öffnenden##und nicht-leerem Inhalt (oder exaktem##für eine leere H2). Zusätzliche Trennzeichen-Leerzeichen/Tabs und eine CommonMark-Schließ-Hash-Sequenz sind hier nicht kanonisch. Die Überschrift ist Teil des zusammengeführten Inhalts, keine Konfiguration, die davon abweichen kann. - Genau eine H1/H2-Überschrift innerhalb des Blocks – seine eigene erste ATX-Zeile (literal-regions-bewusster Zähler). Ein Block mit einer zweiten ATX- oder Setext-H1/H2 würde einen Abschnitt bei einem späteren Durchlauf stillschweigend absorbieren oder teilen. Lehnen Sie mögliche Setext-Überschriften vor dem ersten Schreiben ab, sodass Anhängen und Ersetzen dieselbe Validierungsgrenze verwenden. Umschlossene oder rohe HTML-Überschriftenliterale innerhalb des Blocks sind in Ordnung – sie zählen nicht.
- Höchstens eine eindeutige Kopie der Überschrift im Ziel (außerhalb literal-Regionen). Wenn das Dokument bereits Duplikate oder ein Trennzeichen / 1–3-Leerzeichen / schließende-Hash-Alias der verwalteten H2 enthält, stoppen und melden, anstatt eine Form zu „reparieren“ und eine andere zu hinterlassen.
- Eine kanonische Trennzeichenform. Speichern Sie den Block ohne nachfolgende Leerzeilen; schreiben Sie genau eine Leerzeile zwischen dem Block und einem folgenden Abschnitt. Lesebereich und Schreibform konvergieren dann zu denselben Bytes, was den zweiten Durchlauf zu einer No-Operation macht.
- Keine nicht geschlossenen Fences, weder im Ziel noch im Block. CommonMark führt einen nicht geschlossenen Fence bis zum Ende des Dokuments, daher würde er in einem Ziel den Abschnitt stillschweigend bis EOF ausdehnen (eine Ersetzung würde den gesamten visuell verschluckten Rest neu schreiben), und in einem Block würde er verschlucken, was auf den zusammengeführten Abschnitt folgt. Beides ist fehlerhafte Eingabe: stoppen und melden.
- Keine Setext-Überschriften innerhalb des kanonischen Blocks oder des ersetzten Bereichs.
Eine
===- oder----Unterstreichung direkt unter Absatztext ist eine echte Überschrift, die ein zeilenweiser^#-Scan nicht als Grenze sehen kann. Das Akzeptieren einer solchen in einem neu angehängten Block würde den nächsten Durchlauf scheitern lassen anstatt konvergieren; das Ersetzen darüber würde eine Abschnittsgrenze löschen. Stoppen Sie vor dem Schreiben in beiden Fällen (konvertieren Sie die Überschrift in ATX-Form oder verwenden Sie fixe Markierungen). Siehedocs/setext-block-heading-contract.md. - Kein nicht geschlossenes führendes Frontmatter. Wenn die erste Zeile exaktes
---(YAML) oder+++(TOML) ist, ignorieren Sie ihre überschriftähnlichen Zeilen nur bis zum ersten exakten passenden Schließer. Wenn kein Schließer existiert, stoppen Sie, anstatt zu raten, ob der Öffner Metadaten oder ein gewöhnlicher Markdown-Themenwechsel war. - Kein mehrdeutiges explizites Ende eines rohen HTML-Blocks. CommonMark lässt Typen 1–5 bis EOF laufen, wenn ihre Endbedingung fehlt, aber das Anhängen einer H2 dort würde sie in rohem HTML verstecken und die Konvergenz brechen. Fordern Sie die explizite Endbedingung sowohl im Ziel als auch im Block. Für Typ 7 bewahren Sie seine Regel zur Nicht-Unterbrechung von Absätzen und schlagen Sie fehl, wenn ein nicht unterstützter Container/eingerückter oder möglicher Referenz-plus-Setext-Kontext, einschließlich eines mehrzeiligen Link-Labels, diese Entscheidung mehrdeutig macht.
- ASCII-only Leerraum im Block. Leerzeilen, ATX-Überschriften-Trimming und Schließsequenzen, Setext-Kontext und Fence-Schließer verwenden ASCII-Leerzeichen/Tab genau wie von CommonMark 0.31.2 spezifiziert. NBSP, EM SPACE, Seitenvorschub, vertikaler Tab und anderer Unicode-Leerraum bleiben Inhalt anstatt stillschweigend entfernt zu werden. Siehe
docs/commonmark-ascii-whitespace-contract.md.
Überprüfungs-Rezept
Führen Sie alle drei Prüfungen nach einem Merge durch; zusammen decken sie Grenzfehler, doppelte Abschnitte und Kollateraländerungen auf:
-
Apply-Twice-Diff-Null. Wenden Sie denselben Merge zweimal an; nach dem zweiten Durchlauf muss
git diffleer sein (oder Bytes vorher/nachher vergleichen). Für einen Abschnitt am Ende der Datei ist eine äquivalente Prüfung, dass der Block dem gesamten Endabschnitt der Datei entspricht.python scripts/merge_section.py ziel.md abschnitt.md python scripts/merge_section.py ziel.md abschnitt.md # zweiter Durchlauf git diff --exit-code ziel.md # nach dem Committen des ersten Durchlaufs: leerDer
--check-Modus der Referenzimplementierung kodiert dieselbe Idee mit diff-artigen Exit-Codes (0 = kanonisch, 1 = ein Merge würde die Datei ändern), die CI direkt aufrufen kann. -
Überschriften-Vorkommenszahl = 1. Zählen Sie Zeilen, die der Überschrift entsprechen, mit einem fence-bewussten Scan. Ein bloßes
grep -c '^## Automatisierungsnotizen$'unterliegt genau der Falle, um die es in diesem Skill geht – akzeptabel nur, wenn Sie wissen, dass der Textkörper keine umschlossene Kopie der exakten Überschriftzeile enthält. -
git diff --statberührt genau eine Datei – das Ziel. Alles andere bedeutet, dass die Automatisierung dorthin geschrieben hat, wo sie nicht sollte.
Referenzimplementierung
scripts/merge_section.py implementiert Methode 1 plus jede der obigen Invarianten in abhängigkeitsfreiem Python 3 (nur Standardbibliothek). Python wurde aus einem Grund gewählt, der hier zählt: Explizite Byte-Level-Kontrolle von Zeilenumbrüchen und BOM macht apply-twice-diff-zero beweisbar, wohingegen Shell-Text-Pipelines dazu neigen, Zeilenenden hinter Ihrem Rücken zu normalisieren. Der Algorithmus lässt sich direkt in jede Sprache portieren.
python scripts/merge_section.py ZIEL.md ABSCHNITT.md # Merge an Ort und Stelle
python scripts/merge_section.py ZIEL.md ABSCHNITT.md --check # Abweichungsprüfung
ABSCHNITT.md ist der kanonische Block: erste Zeile ist exakt ## Überschrift, mit einem ASCII-Leerzeichen vor dem nicht-leeren Inhalt, oder exaktem ## für eine leere H2.
Der LF/CRLF-Stil und die UTF-8-BOM des Ziels bleiben erhalten; ein fehlendes Ziel wird erstellt (Anhängen in ein leeres Dokument). Geänderte Bytes werden in eine exklusive temporäre Datei im selben Verzeichnis geschrieben und mit einem atomaren Ersetzen installiert. Die temporäre Datei beginnt mit POSIX-Modus 0600 oder einer geschützten Windows-SYSTEM/Besitzer-Rechte-nur-DACL, und ihre Identität, Bytes, Metadaten und Windows-DACL werden vor dem Commit erneut geprüft. Vorhandene POSIX-Besitzer/Gruppe/Modus/begrenzte erweiterte Attribute bleiben erhalten. Für ein vorhandenes Windows-Ziel trägt ReplaceFileW seine dokumentierte DACL, Dateiattribute und benannten Streams fort; eine exakte Besitzer/Gruppe/SACL-Erhaltung wird nicht zugesichert. Fehlende Ziele behalten die privaten Berechtigungen.
Sowohl Ziel als auch kanonischer Block werden als No-Follow-gewöhnliche-Datei-Snapshots gelesen. Symlink-, nicht-reguläre und Multi-Hard-Link-Eingaben werden abgelehnt, da eine Ersetzung ihre Semantik ändern würde; Windows-Reparse-Points und EFS-verschlüsselte Windows-Ziele werden abgelehnt, bevor der Inhalt gelesen wird.
Die Zielidentität, Metadaten und Bytes werden unmittelbar vor dem Commit erneut geprüft. Änderungen, die vor dieser Prüfung abgeschlossen wurden, werden erkannt, aber die Prüfung und die Ersetzung sind getrennte Operationen; serialisieren Sie extern jeden Schreiber, wenn ein vorhandenes Ziel keinen verlorenen Update erleiden darf. Die Erstellung eines fehlenden Ziels verwendet einen No-Replace-Commit und überschreibt niemals eine gleichzeitige Erstellung. Unter Windows verwendet ReplaceFileW einen bestimmten Backup-Semantik-Flag, damit der Empfänger Datei-Metadaten durch das Ziel hindurch konsultieren kann, aber die Semantik der Sicherung bleibt dieselbe.