Fusión Idempotente de Sección Markdown
Procedimiento para mantener una sección canónica de ## Encabezado en un documento
Markdown bajo automatización: reemplazar la sección cuando existe, agregarla
cuando no existe, y producir una salida idéntica byte a byte al aplicarse dos veces. La
parte no obvia es dónde termina la sección — y el error clásico es
confiar en una expresión regular ^## para encontrar ese límite.
Cuándo Usarlo
- Un agente o script mantiene una sección canónica actualizada en un documento que
no posee completamente —
README.md,AGENTS.md,CLAUDE.md, un manual, un preámbulo de changelog — bajo la regla de "reemplazar si está presente, agregar si falta". - Ves alguno de estos síntomas:
- La sección mantenida se duplica después de cada ejecución.
- Una fusión cortó la sección prematuramente en medio de un bloque de código delimitado.
- Una línea
## ...dentro de un bloque de código se trató como la siguiente sección. - Un subencabezado
###terminó el rango de reemplazo antes de tiempo. - El actualizador nunca converge: la segunda ejecución sigue cambiando el archivo.
- Después de una fusión, todo lo que está debajo de algún punto se renderiza como un bloque de código gigante (un delimitador de bloque sobrante se reabre como un nuevo bloque).
- Estás leyendo o contando secciones con patrones tipo
^##, no solo escribiéndolas — la extracción y verificación caen en la misma trampa.
La Trampa: Encabezados Dentro de Bloques de Código
La implementación ingenua toma el rango de reemplazo como "la línea ## X hasta
la línea anterior a la siguiente coincidencia de ^##". Esto falla de dos maneras.
Trampa 1 — literales dentro de fences. Los cuerpos de las secciones contienen
legítimamente líneas ## ... dentro de bloques de código delimitados: una plantilla
de informe, un documento de ejemplo, un diff citado. Un escaneo ciego a los
fences lee erróneamente la primera línea de ese tipo como el límite de la
siguiente sección y corta el rango allí:
## Notas de automatización
El bot actualiza esta sección.
```text
## Informe semanal <- el escaneo ingenuo de ^## se detiene aquí, dentro del fence
- aspectos destacados:
- riesgos:
```
Mantén la plantilla dentro del fence para que no se convierta en un encabezado real.
## Licencia <- la siguiente sección real
Reemplazar hasta la línea dentro del fence deja una cola obsoleta. La corrupción medida (ver el fixture de prueba de la trampa) es peor que una simple duplicación:
- El literal
## Informe semanaldentro del fence se queda fuera de cualquier fence y se renderiza como un encabezado real, que parece duplicado. - El delimitador de cierre del bloque antiguo (la línea
```sin contenido) también se queda, se reabre como un nuevo fence, y se traga todo lo que sigue — la siguiente sección## Licenciadeja de ser un encabezado. - La fusión no es idempotente: cada ejecución vuelve a coincidir con el encabezado, corta en una línea dentro del fence otra vez, y agranda el archivo otra vez.
Trampa 2 — subencabezados ###. Una expresión regular ^## desnuda también
coincide con ###, ####, etc. Si la sección contiene subencabezados, el
rango termina en la primera línea ### ... y las subsecciones antiguas sobreviven
después de las nuevas — contenido duplicado debajo del punto de fusión.
Las mismas dos trampas se aplican a leer una sección (extraer su contenido)
y a verificar una (grep -c '^## X' puede tanto sobrecontar literales
dentro de fences como ser engañado por ellos).
Límites Seguros: Dos Métodos
Método 1 — escaneo de encabezados consciente de regiones literales
Rastrear el estado de región literal mientras se escanean líneas, y solo tratar una línea como encabezado o límite cuando está fuera del frontmatter, los fences y los bloques HTML sin procesar admitidos:
- Un fence se abre en una línea cuyos primeros caracteres no espaciales (como máximo 3 espacios al inicio) son una secuencia de 3+ acentos graves o 3+ virgulillas.
- Se cierra solo en una línea con una secuencia del mismo carácter, al menos tan larga, sin nada más en la línea. Un fence no cerrado llega hasta el final del documento (comportamiento CommonMark).
- Mientras se está dentro de un fence, nada es un encabezado y nada es un límite.
- Antes del escaneo de fences, se excluye un bloque de frontmatter inicial de línea exacta:
YAML se abre con
---y se cierra con---exacto o...; TOML se abre y cierra con+++exacto. Una línea con apariencia de encabezado dentro de ese bloque es contenido de metadatos/comentario, no un límite de sección Markdown. Un apertura sin su cierre exacto falla de forma segura antes de cualquier escritura. - En el nivel superior, las columnas 0–3 de los
bloques HTML sin procesar CommonMark 0.31.2
de tipos 1–7 también se excluyen: etiquetas de contenido literal
(
pre/script/style/textarea), comentarios, instrucciones de procesamiento, declaraciones, CDATA, etiquetas de bloque listadas y líneas de solo etiqueta completas. Los tipos 6/7 terminan antes de una línea en blanco o en EOF; el tipo 7 no puede interrumpir un párrafo. La gramática de etiquetas y atributos sigue siendo solo ASCII incluso bajo coincidencia sin distinción de mayúsculas/minúsculas; los homoglifos Unicode de pliegue de mayúsculas son Markdown ordinario. El estado de fence y HTML comparte un único escáner exclusivo, por lo que los delimitadores dentro de la otra región se mantienen como datos literales. - Esta referencia mutante es intencionalmente más estricta que CommonMark en un EOF
ambiguo: un apertura reconocida de tipo 1–5 sin su condición de cierre explícita
falla de forma segura. Una posible apertura de tipo 7 después de un contexto de
contenedor/sangría no compatible también falla de forma segura en lugar de adivinar. La misma
regla cubre una posible definición de referencia de enlace seguida de
===, donde un analizador parcial no puede probar si el setext terminó el párrafo. Las posibles etiquetas de enlace se rastrean a través de líneas con manejo de corchetes consciente de escapes; un]sin escapar sin dos puntos inmediatos libera el estado de posible definición. La forma simple de definición sin línea en blanco más etiqueta sigue siendo HTML en línea. - El límite de la sección es el siguiente encabezado de nivel 1 o 2. La forma
popular es
^##[^#](excluir###); la referencia lo refuerza a^ {0,3}#{1,2}([ \t]|$), que además trata un H1 como un límite (un límite de parte nunca debe ser tragado en un reemplazo), acepta la sangría de 1 a 3 espacios que CommonMark permite y aplica la regla de espacio de CommonMark para que una línea de párrafo de estilo##hashtagno sea un límite falso.###sigue sin ser un límite de todos modos.
Usa este método cuando el formato del documento no se puede cambiar, o cuando la herramienta debe funcionar en Markdown arbitrario.
Método 2 — marcadores fijos
Envuelve la sección mantenida en líneas centinela únicas — los comentarios HTML son invisibles en Markdown renderizado:
<!-- managed-section:begin automation-notes -->
## Notas de automatización
...cuerpo, plantillas dentro de fences bienvenidas...
<!-- managed-section:end automation-notes -->
Reemplaza todo entre las líneas exactas de marcador de inicio/fin; agrega todo el bloque marcado cuando el marcador de inicio está ausente. El escaneo de encabezados ya no es parte de la búsqueda de rangos.
Usa este método cuando posees el formato del documento: es el límite más robusto (sobrevive a renombrados de encabezados y cualquier contenido del cuerpo), a costa de colocar marcadores. Mantén las cadenas de marcador únicas por sección y mantén la invariante de H2 único a continuación incluso aquí — los marcadores protegen el rango, no la estructura de encabezados del documento. Una nota de honestidad: una copia literal de la línea de marcador dentro de un fence aún confundiría una búsqueda de marcador ciega a fences, así que mantén el texto del marcador fuera de ejemplos dentro de fences o encuentra marcadores con el mismo escaneo consciente de fences.
Invariantes
Aplica estas antes y después de cada fusión; hacen que "reemplazar o agregar" esté bien definido:
- El bloque comienza con su propia línea
## Encabezadosin formato — usa exactamente un espacio ASCII entre la##de apertura y el contenido no vacío (o exactamente##para un H2 vacío). Los espacios/tabuladores separadores adicionales y una secuencia de cierre con hash de CommonMark no son canónicos aquí. El encabezado es parte del contenido fusionado, no una configuración que pueda desviarse de él. - Exactamente un encabezado de nivel H1/H2 dentro del bloque — su propia primera línea ATX (recuento consciente de regiones literales). Un bloque con un segundo ATX o setext H1/H2 absorbería o dividiría silenciosamente una sección en una ejecución posterior. Rechaza posibles encabezados setext antes de la primera escritura para que agregar y reemplazar usen el mismo límite de validación. Los literales de encabezado dentro de fences o HTML sin procesar dentro del bloque están bien — no cuentan.
- Como máximo una copia inequívoca del encabezado en el destino (fuera de regiones literales). Si el documento ya contiene duplicados o un separador / espacio 1–3 / alias de cierre hash del H2 gestionado, detente e informa en lugar de "arreglar" una forma y dejar otra.
- Una forma de separador canónica. Almacena el bloque sin líneas en blanco finales; escribe exactamente una línea en blanco entre el bloque y la sección siguiente. El rango de lectura y la forma de escritura convergen entonces a los mismos bytes, que es lo que hace que la segunda ejecución sea un no-op.
- Sin fences no cerrados, en el destino o en el bloque. CommonMark ejecuta un fence no cerrado hasta el final del documento, por lo que en un destino extiende silenciosamente la sección hasta EOF (un reemplazo reescribiría toda la cola visualmente tragada), y en un bloque se tragaría lo que sigue a la sección fusionada. Ambas son entradas mal formadas: detente e informa.
- Sin encabezados setext dentro del bloque canónico o del intervalo reemplazado.
Un subrayado
===o---directamente debajo del texto de un párrafo es un encabezado real que un escaneo línea por línea de^#no puede ver como un límite. Aceptar uno en un bloque recién agregado haría que la siguiente ejecución falle en lugar de converger; reemplazar a través de uno eliminaría un límite de sección. Detente antes de escribir en ambos casos (convierte el encabezado a forma ATX, o usa marcadores fijos). Verdocs/commonmark-setext-heading-contract.md. - Sin frontmatter inicial no cerrado. Cuando la primera línea es exactamente
---(YAML) o+++(TOML), ignora sus líneas con apariencia de encabezado solo hasta el primer cierre exacto coincidente. Si no existe un cierre, detente en lugar de adivinar si la apertura era metadatos o una ruptura temática ordinaria de Markdown. - Sin HTML sin procesar de final explícito ambiguo. CommonMark permite que los tipos 1–5 se ejecuten hasta EOF cuando falta su condición de final, pero agregar un H2 allí lo ocultaría dentro del HTML sin procesar y rompería la convergencia. Requiere la condición de final explícita tanto en el destino como en el bloque. Para el tipo 7, preserva su regla de no interrupción de párrafo y falla de forma segura cuando un contexto de contenedor/sangría no compatible o de posible referencia más setext, incluyendo una etiqueta de enlace de varias líneas, hace que esa decisión sea ambigua.
- Espacio en blanco de bloque solo ASCII. Las líneas en blanco, el recorte de encabezados ATX y
las secuencias de cierre, el contexto setext y los cerradores de fence usan espacio/tabulador ASCII exactamente como
CommonMark 0.31.2 especifica. NBSP, EM SPACE, avance de página, tabulación vertical y
otros espacios en blanco Unicode permanecen como contenido en lugar de ser eliminados silenciosamente.
Ver
docs/commonmark-ascii-whitespace-contract.md.
Receta de Verificación
Ejecuta las tres comprobaciones después de una fusión; juntas detectan errores de límite, secciones duplicadas y ediciones colaterales:
-
Aplicar-dos-veces-diferencia-cero. Aplica la misma fusión dos veces; después de la segunda ejecución,
git diffdebe estar vacío (o comparar bytes antes/después). Para una sección que se encuentra al final del archivo, una comprobación equivalente es que el bloque es igual a toda la sección final del archivo.python scripts/merge_section.py target.md section.md python scripts/merge_section.py target.md section.md # segunda ejecución git diff --exit-code target.md # después de confirmar la primera ejecución: vacíoEl modo
--checkde la implementación de referencia codifica la misma idea con códigos de salida de estilo diff (0 = canónico, 1 = una fusión cambiaría el archivo), que CI puede llamar directamente. -
Recuento de ocurrencias de encabezado = 1. Cuenta las líneas iguales al encabezado con un escaneo consciente de fences. Un
grep -c '^## Notas de automatización$'está sujeto a la misma trampa de la que trata esta habilidad — aceptable solo cuando sabes que el cuerpo no contiene una copia dentro de un fence de la línea de encabezado exacta. -
git diff --stattoca exactamente un archivo — el destino. Cualquier otra cosa significa que la automatización escribió donde no debía.
Implementación de Referencia
scripts/merge_section.py implementa el Método 1
más cada invariante anterior en Python 3 sin dependencias (biblioteca estándar
solamente). Python se eligió sobre un shell por una razón que importa aquí:
control explícito a nivel de bytes de nuevas líneas y BOM hace que aplicar-dos-veces-diferencia-cero
sea demostrable, donde las tuberías de texto de shell tienden a normalizar los finales de línea
sin que te des cuenta. El algoritmo se porta directamente a cualquier lenguaje.
python scripts/merge_section.py TARGET.md SECTION.md # fusionar en el lugar
python scripts/merge_section.py TARGET.md SECTION.md --check # comprobación de desviación
SECTION.md es el bloque canónico: la primera línea es exactamente ## Encabezado, con un
espacio ASCII antes de contenido no vacío, o exactamente ## para un H2 vacío.
El estilo LF/CRLF y el BOM UTF-8 del destino se preservan; un destino faltante se
crea (agregar en un documento vacío). Los bytes cambiados se vacían a un
archivo temporal exclusivo del mismo directorio y se instalan con un reemplazo atómico.
El temporal comienza con modo POSIX 0600 o una DACL protegida de Windows
SISTEMA/Solo-Dueño, y su identidad, bytes, metadatos y DACL de Windows
se vuelven a comprobar antes de la confirmación. Los atributos extendidos POSIX existentes de
propietario/grupo/modo/acotados se preservan. Para un destino de Windows existente,
ReplaceFileW traslada su DACL documentada, atributos de archivo y flujos
nombrados; no se promete preservación exacta de propietario/grupo/SACL. Los destinos faltantes
conservan los permisos privados.
Tanto el destino como el bloque canónico se leen como instantáneas de archivo ordinario sin seguimiento.
Las entradas de enlace simbólico, no regulares y de múltiples enlaces duros se rechazan porque
el reemplazo cambiaría su semántica; los puntos de reanálisis de Windows y
los destinos de Windows cifrados con EFS se rechazan antes de leer el contenido.
La identidad, metadatos y bytes del destino se vuelven a comprobar justo antes de la confirmación.
Los cambios completados antes de esa comprobación se detectan, pero la comprobación y el reemplazo
son operaciones separadas; serializa externamente a cada escritor cuando un destino existente
no debe sufrir una actualización perdida. La creación de destino faltante usa una
confirmación sin reemplazo y nunca sobrescribe una creación concurrente. En Windows, ReplaceFileW usa un
...
[truncado para traducción]