Markdown Idempotent Section Merge (멱등적 섹션 병합)
자동화 환경에서 Markdown 문서의 하나의 정규 ## Heading 섹션을 유지하기 위한 절차: 섹션이 존재하면 교체하고, 존재하지 않으면 추가하며, 두 번 적용했을 때 바이트 단위 동일한 출력을 생성합니다. 까다로운 부분은 섹션이 끝나는 지점이며, 흔한 실수는 단순한 ^## 정규식으로 그 경계를 찾는 것입니다.
사용 시기
- 에이전트나 스크립트가 완전히 소유하지 않은 문서(
README.md,AGENTS.md,CLAUDE.md, 핸드북, 변경 로그 서문 등)에서 정규 섹션을 최신 상태로 유지해야 할 때, "있으면 교체, 없으면 추가" 규칙을 적용합니다. - 다음 증상 중 하나라도 발견되면 사용하세요:
- 유지 관리되는 섹션이 실행할 때마다 중복됩니다.
- 병합이 펜스 처리된 코드 블록 중간에서 섹션을 잘라냈습니다.
- 코드 펜스 내부의
## ...줄이 다음 섹션으로 처리되었습니다. ###하위 제목이 교체 범위를 일찍 종료시켰습니다.- 업데이터가 수렴하지 않습니다: 두 번째 실행에서도 여전히 파일이 변경됩니다.
- 병합 후 아래쪽 모든 내용이 하나의 거대한 코드 블록으로 렌더링됩니다(남은 펜스 구분자가 새 펜스로 다시 열림).
^##스타일 패턴으로 섹션을 읽거나 계산하고 있을 때도(쓰기뿐 아니라 추출 및 검증에도 동일한 함정이 적용됩니다).
함정: 코드 펜스 내부의 제목
간단한 구현은 교체 범위를 "## X 줄부터 다음 ^## 일치 항목 바로 전 줄까지"로 잡습니다. 이는 두 가지 방식으로 깨집니다.
함정 1 — 펜스 리터럴. 섹션 본문에는 코드 펜스 내부에 ## ... 줄이 정당하게 포함될 수 있습니다: 보고서 템플릿, 샘플 문서, 인용된 diff 등이 그 예입니다. 펜스를 무시하는 스캔은 첫 번째 그러한 줄을 다음 섹션 경계로 오인하고 그 위치에서 범위를 자릅니다:
## 자동화 노트
봇이 이 섹션을 새로고침합니다.
```text
## 주간 보고서 <- naive ^## 스캔은 펜스 내부에서 여기서 멈춥니다
- 하이라이트:
- 위험:
```
템플릿을 펜스 안에 유지하여 실제 제목이 되지 않도록 합니다.
## 라이선스 <- 실제 다음 섹션
펜스로 처리된 줄까지 교체하면 뒤에 오래된 꼬리가 남습니다. 측정된 손상(테스트된 트랩 픽스처 참조)은 단순한 중복보다 더 심각합니다:
- 펜스 안의
## 주간 보고서리터럴이 펜스 밖에 남아 실제 제목처럼 보이는 중복으로 렌더링됩니다. - 이전 블록의 닫는 펜스 구분자(
```줄)도 남아 새 펜스로 다시 열리며 그 뒤의 모든 것을 삼킵니다 — 다음## 라이선스섹션은 더 이상 제목이 아닙니다. - 병합이 멱등적이지 않습니다: 매 실행마다 제목을 다시 찾고, 펜스 처리된 줄에서 다시 자르고, 파일이 다시 커집니다.
함정 2 — ### 하위 제목. 단순한 ^## 정규식은 ###, #### 등도 일치시킵니다. 섹션에 하위 제목이 포함된 경우 범위가 첫 번째 ### ... 줄에서 끝나고 이전 하위 섹션이 새 하위 섹션 뒤에 살아남습니다 — 병합 지점 아래에 콘텐츠가 중복됩니다.
동일한 두 함정은 섹션 읽기(콘텐츠 추출)와 검증(grep -c '^## X'는 펜스 리터럴을 과다 계산하고 속을 수 있음)에도 적용됩니다.
안전한 경계: 두 가지 방법
방법 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 전용으로 유지됩니다. 유니코드 대소문자 유사 항목은 일반 Markdown입니다. 펜스와 HTML 상태는 하나의 배타적 스캐너를 공유하므로 다른 영역 내의 구분자는 리터럴 데이터로 유지됩니다. - 이 변경 참조는 모호한 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 불변성을 여기서도 유지하십시오 — 마커는 범위를 보호하며 문서의 제목 구조를 보호하지 않습니다. 한 가지 정직한 참고: 코드 펜스 내부의 마커 줄 리터럴 사본은 여전히 펜스를 무시하는 마커 검색을 혼란스럽게 하므로, 펜스 예제에서 마커 텍스트를 피하거나 동일한 펜스 인식 스캔으로 마커를 찾으십시오.
불변성
모든 병합 전후에 다음을 시행하십시오. 이는 "교체 또는 추가"를 잘 정의되게 만듭니다:
- 블록은 자체 일반
## Heading줄로 시작합니다 — 여는##과 비어 있지 않은 콘텐츠 사이에 정확히 하나의 ASCII 공백을 사용하십시오(또는 빈 H2의 경우 정확히##). 추가 구분 공백/탭과 CommonMark 닫는 해시 시퀀스는 여기서 정규가 아닙니다. 제목은 병합된 콘텐츠의 일부이며, 이와 분리될 수 있는 구성이 아닙니다. - 블록 내부에 정확히 하나의 H1/H2 수준 제목 — 자체 첫 번째 ATX 줄(리터럴 영역 인식 카운트)입니다. 두 번째 ATX 또는 setext H1/H2가 있는 블록은 이후 실행에서 섹션을 조용히 흡수하거나 분할합니다. 첫 번째 쓰기 전에 가능한 setext 제목을 거부하여 추가와 교체가 동일한 검증 경계를 사용하도록 합니다. 블록 내부의 펜스 또는 원시 HTML 제목 리터럴은 괜찮습니다 — 계산되지 않습니다.
- 대상에 제목의 명확한 사본이 최대 하나 (리터럴 영역 외부). 문서에 이미 중복 또는 관리되는 H2의 구분 기호 / 1–3-공백 / 닫는 해시 별칭이 포함된 경우, 하나의 형식을 "수정"하고 다른 형식을 남기는 대신 중지하고 보고하십시오.
- 정규 구분 기호 모양. 블록을 후행 빈 줄 없이 저장하십시오. 블록과 다음 섹션 사이에 정확히 하나의 빈 줄을 쓰십시오. 그러면 읽기 범위와 쓰기 모양이 동일한 바이트로 수렴하며, 이것이 두 번째 실행을 no-op으로 만듭니다.
- 대상 또는 블록에 닫히지 않은 펜스가 없어야 합니다. 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, 용지 공급, 수직 탭 및 기타 유니코드 공백은 조용히 제거되는 대신 콘텐츠로 유지됩니다.
docs/commonmark-ascii-whitespace-contract.md를 참조하십시오.
검증 레시피
병합 후 세 가지 확인을 모두 실행하십시오. 함께 경계 버그, 중복 섹션 및 부수적 편집을 잡아냅니다:
-
두 번 적용 시 차이 없음. 동일한 병합을 두 번 적용하십시오. 두 번째 실행 후
git diff는 비어 있어야 합니다(또는 전후 바이트 비교). 파일 끝에 있는 섹션의 경우 동등한 확인은 블록이 파일의 전체 꼬리 섹션과 같은지 확인하는 것입니다.python scripts/merge_section.py target.md section.md python scripts/merge_section.py target.md section.md # 두 번째 실행 git diff --exit-code target.md # 첫 번째 실행 커밋 후: 비어 있음참조 구현의
--check모드는 diff 스타일 종료 코드(0 = 정규, 1 = 병합이 파일을 변경함)로 동일한 아이디어를 인코딩하며, CI에서 직접 호출할 수 있습니다. -
제목 발생 횟수 = 1. 펜스 인식 스캔으로 제목과 동일한 줄을 계산하십시오. 단순한
grep -c '^## 자동화 노트$'는 이 스킬이 다루는 바로 그 함정에 취약합니다 — 본문에 정확한 제목 줄의 펜스 처리된 사본이 없다는 것을 알고 있는 경우에만 허용됩니다. -
git diff --stat가 정확히 하나의 파일을 변경함 — 대상. 다른 것은 자동화가 쓰지 말아야 할 곳에 썼음을 의미합니다.
참조 구현
scripts/merge_section.py는 종속성 없는 Python 3(표준 라이브러리만 사용)으로 방법 1과 위의 모든 불변성을 구현합니다. Python이 여기서 중요한 한 가지 이유로 셸 대신 선택되었습니다: 새 줄과 BOM의 명시적 바이트 수준 제어는 두 번 적용 시 차이 없음을 증명 가능하게 만드는 반면, 셸 텍스트 파이프라인은 사용자 모르게 줄 끝을 정규화하는 경향이 있습니다. 알고리즘은 모든 언어로 직접 포팅됩니다.
python scripts/merge_section.py TARGET.md SECTION.md # 제자리 병합
python scripts/merge_section.py TARGET.md SECTION.md --check # 드리프트 확인
SECTION.md는 정규 블록입니다: 첫 번째 줄은 정확히 ## Heading이며, 비어 있지 않은 콘텐츠 앞에 하나의 ASCII 공백이 있거나 빈 H2의 경우 정확히 ##입니다. 대상의 LF/CRLF 스타일과 UTF-8 BOM은 보존됩니다. 대상이 없으면 생성됩니다(빈 문서에 추가). 변경된 바이트는 동일한 디렉토리의 배타적 임시 파일로 플러시되고 한 번의 원자적 교체로 설치됩니다. 임시 파일은 POSIX 모드 0600 또는 보호된 Windows SYSTEM/소유자-권리 전용 DACL로 시작하며, 해당 ID, 바이트, 메타데이터 및 Windows DACL은 커밋 전에 다시 확인됩니다. 기존 POSIX 소유자/그룹/모드/제한된 확장 속성은 보존됩니다. 기존 Windows 대상의 경우 ReplaceFileW는 문서화된 DACL, 파일 속성 및 명명된 스트림을 전달합니다. 정확한 소유자/그룹/SACL 보존은 보장되지 않습니다. 누락된 대상은 개인 권한을 유지합니다.
대상과 정규 블록 모두 팔로우하지 않는 일반 파일 스냅샷으로 읽힙니다. 심볼릭 링크, 비정규 파일 및 다중 하드 링크 입력은 교체가 의미 체계를 변경하므로 거부됩니다. Windows 재분석 지점 및 EFS 암호화 Windows 대상은 콘텐츠를 읽기 전에 거부됩니다.
대상 ID, 메타데이터 및 바이트는 커밋 직전에 다시 확인됩니다. 해당 확인 전에 완료된 변경은 감지되지만, 확인과 교체는 별도 작업입니다. 기존 대상이 업데이트 손실을 겪지 않아야 하는 경우 모든 작성자를 외부에서 직렬화하십시오. 누락된 대상 생성을 위해 교체 없는 커밋을 사용하며 동시 생성을 덮어쓰지 않습니다. Windows에서 ReplaceFileW는 백업 파일을 사용하므로 로그 전용 또는 교리적 연결이 없는 볼륨(예: 압축되지 않은 WSL 드라이브)에서는 실패합니다.