Markdown 冪等セクションマージ
Markdown文書内の1つの正規となる ## 見出し セクションを自動化で維持するための手順:セクションが存在する場合は置換、存在しない場合は追加し、2回適用するとバイト単位で同一の出力を生成します。直感的でない部分は、セクションが 終わる 場所です。そして古典的な間違いは、単純な ^## 正規表現でその境界を検出しようとすることです。
使用時期
- エージェントまたはスクリプトが、自分が完全には所有していない文書(
README.md、AGENTS.md、CLAUDE.md、ハンドブック、変更履歴のプリアンブルなど)内で正規のセクションを最新に保つ必要がある場合。「存在すれば置換、存在しなければ追加」のルールで動作します。 - 以下のいずれかの症状が見られる場合:
- 管理対象セクションが実行のたびに重複する。
- マージによって、フェンスで囲まれたコードブロックの途中でセクションが途中で切られる。
- コードフェンス 内 の
## ...行が次のセクションとして誤って扱われる。 ###サブ見出しによって置換範囲が早期に終了する。- 更新プログラムが収束しない:2回目の実行でファイルがまだ変更される。
- マージ後、特定のポイント以降のすべてが1つの巨大なコードブロックとしてレンダリングされる(残されたフェンス区切り文字が新しいフェンスとして再び開かれる)。
^##形式のパターンでセクションを 読み取ったり または カウントしたり している場合(書き込みだけでなく)— 抽出と検証も同じ罠に陥ります。
罠:コードフェンス内の見出し
単純な実装では置換範囲を「## X 行から次の ^## マッチの前の行まで」とします。これは2つの方法で壊れます。
罠1 — フェンス内のリテラル。 セクション本文には、コードフェンスで囲まれたブロック内に ## ... 行が正当に含まれることがあります:レポートテンプレート、サンプル文書、引用された差分など。フェンスを認識しないスキャンは、そのような行の最初のものを次のセクション境界と誤読し、範囲をそこで切ってしまいます:
## 自動化に関する注意事項
ボットはこのセクションを更新します。
```text
## 週間レポート <- 単純な ^## スキャンはここで停止(フェンス内)
- ハイライト:
- リスク:
```
テンプレートをフェンスで囲んだままにして実際の見出しにならないようにします。
## ライセンス <- 実際の次のセクション
フェンスで囲まれた行まで置換すると、古い末尾が残ります。測定された破損(テスト済みの罠フィクスチャを参照)は単なる重複よりも悪質です:
- フェンスで囲まれた
## 週間レポートリテラルは、フェンスの 外 に残され、実際の重複した見出しのようにレンダリングされます。 - 古いブロックの閉じフェンス区切り文字(裸の
```行)も残され、新しい フェンスとして再び開かれ、その後のすべてを飲み込みます — 後続の## ライセンスセクションはまったく見出しではなくなります。 - マージは冪等ではありません:実行のたびに見出しに一致し、再びフェンスで囲まれた行で切り取り、再びファイルを拡大します。
罠2 — ### サブ見出し。 単純な ^## 正規表現は ###、#### などにも一致します。セクションにサブ見出しが含まれている場合、範囲は最初の ### ... 行で終了し、古いサブセクションは新しいものの後に残ります — マージポイントの下にコンテンツが重複します。
同じ2つの罠は、セクションの 読み取り(コンテンツの抽出)と 検証(grep -c '^## X' はフェンス内のリテラルを過大カウントする可能性があり、またそれらに惑わされる可能性があります)にも適用されます。
安全な境界:2つの方法
方法1 — リテラル領域認識見出しスキャン
スキャン中にリテラル領域の状態を追跡し、行がフロントマター、フェンス、およびサポートされている生のHTMLブロックの 外 にある場合にのみ、その行を見出しまたは境界として扱います:
- フェンスは、最初の非空白文字(最大3つの先頭スペース)が3個以上のバックティックまたは3個以上のチルダの連続である行で開きます。
- 同じ文字の連続で、少なくとも同じ長さ で、行に他に何もない場合にのみ閉じます。閉じられていないフェンスは文書の最後まで続きます(CommonMarkの動作)。
- フェンス内では、何も見出しではなく、何も境界ではありません。
- フェンススキャンの前に、先頭の正確な行のフロントマターブロックが除外されます:YAMLは
---で開き、正確な---または...で閉じます。TOMLは正確な+++で開き閉じます。そのブロック内の見出しのように見える行は、メタデータ/コメントのコンテンツであり、Markdownセクション境界ではありません。開きに対応する正確な閉じがない場合、書き込みの前に失敗して閉じます。 - トップレベルでは、列0–3の CommonMark 0.31.2 生HTMLブロック タイプ1–7も除外されます:リテラルコンテンツタグ(
pre/script/style/textarea)、コメント、処理命令、宣言、CDATA、リストされたブロックタグ、および完全なタグのみの行。タイプ6/7は空白行の前またはEOFで終了します。タイプ7は段落を中断できません。タグと属性の文法は、大文字小文字を区別しないマッチングでもASCIIのみのままです。Unicodeのケースフォールドの類似物は通常のMarkdownです。フェンスとHTMLの状態は1つの排他的スキャナーを共有するため、一方の領域内の区切り文字はリテラルデータのままです。 - この変更されたリファレンスは、あいまいなEOFにおいてCommonMarkよりも意図的に厳格です:認識されたタイプ1–5の開きに明示的な終了条件がない場合は、失敗して閉じます。サポートされていないコンテナ/インデントコンテキストの後に可能なタイプ7の開きがある場合も、推測する代わりに失敗して閉じます。同じルールは、可能なリンク参照定義の後に
===が続く場合をカバーします。この場合、部分的なパーサーはsetextが段落を終了したかどうかを証明できません。可能なリンクラベルは、エスケープを認識した括弧処理で行をまたいで追跡されます。エスケープされていない]の直後にコロンがない場合、可能な定義状態が解放されます。単純なブランクなしの定義とタグの形式は、インラインHTMLのままです。 - セクション境界は、レベル1または2の次の見出しです。一般的な形式は
^##[^#](###を除外)ですが、リファレンスは^ {0,3}#{1,2}([ \t]|$)に強化されており、さらにH1を境界として扱い(パーツの境界が置換に飲み込まれてはならない)、CommonMarkが許可する1〜3スペースのインデントを受け入れ、CommonMarkのスペースルールを適用して##hashtagスタイルの段落行が誤った境界にならないようにします。###はどちらにしても依然として境界ではありません。
文書の形式が変更できない場合、またはツールが任意のMarkdownで動作する必要がある場合は、この方法を使用します。
方法2 — 固定マーカー
管理対象セクションを一意のセンチネル行で囲みます — HTMLコメントはレンダリングされたMarkdownでは非表示です:
<!-- managed-section:begin automation-notes -->
## 自動化に関する注意事項
...本文、フェンスで囲まれたテンプレートも歓迎...
<!-- managed-section:end automation-notes -->
正確な開始/終了マーカー行の間のすべてを置換します。開始マーカーがない場合は、マークされたブロック全体を追加します。見出しスキャンは範囲検出の一部ではなくなります。
文書形式を所有している場合にこの方法を使用します:これは最も堅牢な境界です(見出しの名前変更や任意の本文コンテンツに耐えます)。ただし、マーカーを埋め込むコストがかかります。マーカー文字列はセクションごとに一意に保ち、以下の単一H2不変条件もここでも維持します — マーカーは範囲 を保護しますが、文書の見出し構造は保護しません。正直な注意点として、コードフェンス内のマーカー行のリテラルコピーは、フェンスを認識しないマーカー検索を依然として混乱させるため、マーカーテキストをフェンスで囲まれた例の外に保つか、同じフェンス認識スキャンでマーカーを見つけます。
不変条件
すべてのマージの前後にこれらを適用します。これらにより、「置換または追加」が明確に定義されます:
- ブロックは、それ自体のプレーンな
## 見出し行で始まります — 開始##と空でないコンテンツの間には正確に1つのASCIIスペースを使用します(または空のH2の場合は正確な##)。追加の区切りスペース/タブとCommonMarkの閉じハッシュシーケンスはここでは正規ではありません。見出しはマージされたコンテンツの一部であり、そこから漂流する可能性のある設定ではありません。 - ブロック内に正確に1つのH1/H2レベルの見出し — それ自体の最初のATX行(リテラル領域認識カウント)。2番目のATXまたはsetext H1/H2を持つブロックは、後の実行でセクションを静かに吸収または分割します。最初の書き込みの前に可能なsetext見出しを拒否して、追加と置換が同じ検証境界を使用するようにします。ブロック内のフェンスまたは生のHTML見出しリテラルは問題ありません — これらはカウントされません。
- ターゲット内に見出しのあいまいでないコピーが最大1つ(リテラル領域の外側)。文書にすでに重複、または管理対象H2の区切り記号/1〜3スペース/閉じハッシュの別名が含まれている場合は、1つの形式を「修正」して別の形式を残すのではなく、停止して報告します。
- 正規の区切り形状。 ブロックを末尾の空白行なしで保存します。ブロックと後続のセクションの間に正確に1つの空白行を書き込みます。読み取り範囲と書き込み形状は同じバイトに収束し、これにより2回目の実行がノーオペレーションになります。
- ターゲット内またはブロック内に、閉じられていないフェンスがないこと。 CommonMarkは閉じられていないフェンスを文書の最後まで実行するため、ターゲット内ではセクションをEOFまで静かに拡張し(置換は視覚的に飲み込まれた末尾全体を書き換える)、ブロック内ではマージされたセクションの後ろにあるものを飲み込みます。どちらも不正な入力です:停止して報告します。
- 正規のブロック内または置換されたスパン内にsetext見出しがないこと。 段落テキストの直下の
===または---の下線は、行単位の^#スキャンでは境界として認識できない実際の見出しです。新しく追加されたブロックでそれを受け入れると、次の実行が収束する代わりに失敗します。また、それらを置換すると、セクション境界が削除されます。両方の場合で書き込みの前に停止します(見出しをATX形式に変換するか、固定マーカーを使用します)。docs/setext-block-heading-contract.mdを参照してください。 - 閉じられていない先頭フロントマターがないこと。 最初の行が正確な
---(YAML)または+++(TOML)である場合、最初の正確な一致クローザーを介してのみ、その見出しのように見える行を無視します。クローザーが存在しない場合は、オープナーがメタデータであったか、通常のMarkdown主題区切りであったかを推測する代わりに停止します。 - あいまいな明示的終了生HTMLがないこと。 CommonMarkはタイプ1〜5を、終了条件がない場合にEOFまで実行することを許可しますが、そこにH2を追加すると生HTML内に隠れて収束が壊れます。ターゲットとブロックの両方で明示的な終了条件を要求します。タイプ7については、段落中断禁止ルールを維持し、サポートされていないコンテナ/インデントまたは可能な参照とsetextのコンテキスト(複数行のリンクラベルを含む)によってその決定があいまいになる場合に失敗して閉じます。
- ASCIIのみのブロック空白。 空白行、ATX見出しのトリムと閉じシーケンス、setextコンテキスト、およびフェンスクローザーは、CommonMark 0.31.2で指定されているとおりにASCIIスペース/タブを正確に使用します。NBSP、EM SPACE、フォームフィード、垂直タブ、およびその他のUnicode空白は、静かに削除される代わりにコンテンツのままになります。
docs/commonmark-ascii-whitespace-contract.mdを参照してください。
検証レシピ
マージ後に3つのチェックすべてを実行します。これらは連携して、境界のバグ、重複セクション、および付随的な編集を検出します:
-
2回適用で差分ゼロ。 同じマージを2回適用します。2回目の実行後、
git diffは空である必要があります(または前後のバイトを比較します)。ファイルの末尾にあるセクションの場合、同等のチェックは、ブロックがファイルの末尾セクション全体と等しいことです。python scripts/merge_section.py target.md section.md python scripts/merge_section.py target.md section.md # 2回目の実行 git diff --exit-code target.md # 最初の実行をコミット後:空リファレンス実装の
--checkモードは、diff形式の終了コード(0 = 正規、1 = マージによってファイルが変更される)で同じアイデアをエンコードしており、CIが直接呼び出すことができます。 -
見出し出現回数 = 1。 フェンス認識スキャンで見出しと等しい行をカウントします。単純な
grep -c '^## 自動化に関する注意事項$'は、まさにこのスキルが対象とする罠の影響を受けます — 本文に正確な見出し行のフェンスで囲まれたコピーが含まれないことがわかっている場合にのみ許容されます。 -
git diff --statは正確に1つのファイルに触れます — ターゲット。それ以外は、自動化が本来あるべきでない場所に書き込んだことを意味します。
リファレンス実装
scripts/merge_section.py は、依存関係のないPython 3(標準ライブラリのみ)で方法1と上記のすべての不変条件を実装しています。Pythonがシェルよりも選択された理由は、ここで重要になる1つの理由があります:改行とBOMの明示的なバイトレベル制御により、2回適用で差分ゼロを証明可能になります。シェルのテキストパイプラインは、バックグラウンドで改行を正規化する傾向があります。このアルゴリズムは任意の言語に直接移植できます。
python scripts/merge_section.py TARGET.md SECTION.md # インプレースマージ
python scripts/merge_section.py TARGET.md SECTION.md --check # ドリフトチェック
SECTION.md は正規のブロックです:最初の行は正確な ## 見出し であり、空でないコンテンツの前には1つのASCIIスペースがあり、空のH2の場合は正確な ## です。ターゲットのLF/CRLFスタイルとUTF-8 BOMは保持されます。存在しないターゲットは作成されます(空の文書に追加)。変更されたバイトは、同じディレクトリ内の排他的な一時ファイルにフラッシュされ、1回のアトミックな置換でインストールされます。一時ファイルはPOSIXモード 0600 または保護されたWindows SYSTEM/Owner-Rights-only DACLで開始され、そのID、バイト、メタデータ、およびWindows DACLはコミットの前に再チェックされます。既存のPOSIX所有者/グループ/モード/境界付き拡張属性は保持されます。既存のWindowsターゲットの場合、ReplaceFileW は文書化されたDACL、ファイル属性、および名前付きストリームを継承します。正確な所有者/グループ/SACLの保持は保証されません。存在しないターゲットはプライベートアクセス許可を保持します。
ターゲットと正規ブロックの両方が、フォローなしの通常ファイルスナップショットとして読み取られます。シンボリックリンク、非通常ファイル、およびマルチハードリンク入力は、置換によってセマンティクスが変更されるため拒否されます。Windowsの再解析ポイントおよびEFS暗号化Windowsターゲットは、コンテンツが読み取られる前に拒否されます。
ターゲットのID、メタデータ、およびバイトは、コミットの直前に再チェックされます。そのチェックの前に完了した変更は検出されますが、チェックと置換は別々の操作です。既存のターゲットが更新の喪失に遭ってはならない場合は、すべてのライターを外部でシリアル化します。存在しないターゲットの作成は置換なしのコミットを使用し、同時作成を上書きすることはありません。Windowsでは、ReplaceFileW は…