El bug de normalización que solo aparece con etiquetas hechas de nada
La última pieza del cluster del editor de blog-manager: un input de etiquetas estilo chip para el editor de posts, con autocompletado extraído de cada etiqueta ya usada en el blog. Se escribe “home-lab,” aparece sugerido “homelab,” se elige, listo. Simple en concepto.
El mecanismo que construí para ese match de “home-lab significa homelab” es una función de normalización: quitar todo excepto letras minúsculas y dígitos, y después comparar. En Ruby: name.to_s.downcase.gsub(/[^a-z0-9]/, ""). La misma idea portada a mano a JS para el autocompletado del lado del cliente, ya que este repo no tiene bundler ni un límite de módulo compartido entre servidor y navegador. Hasta dejé un comentario en la versión de JS que decía “must match Post.normalize_tag,” lo cual, en retrospectiva, es exactamente el tipo de comentario que debería haberme generado sospecha. Un comentario que impone una invariante en vez de código que la impone es una señal de alerta.
Construí esto de la misma forma en que había construido las últimas piezas de este cluster: leer primero el código existente (ya existe un Post.tag_counts del trabajo de operaciones de etiquetas), validar la lógica nueva contra algo concreto, escribir la interfaz, publicar. Una pasada de revisión detectó una pregunta de diseño real antes de escribir ni una línea de JS: mi primer instinto fue hacer que el servidor recanonicalizara el array completo de etiquetas en cada guardado, para que el uso de mayúsculas se mantuviera consistente en todos lados. La objeción: eso reescribiría en silencio etiquetas que nadie tocó, se edita el título de un post, y el uso de mayúsculas de una etiqueta intacta podría cambiar porque algún otro post ahora tiene más instancias de una grafía distinta. Eso es real, así que limité la canonicalización solo a la etiqueta que se está agregando activamente, nunca a las que ya estaban ahí. Buena observación, solución correcta, seguí adelante.
Lo que no pensé: qué pasa cuando alguien etiqueta un post con algo que es puro signo de puntuación. Como literalmente ”!!!” como etiqueta. Nada en mi código lo detiene, "!!!".trim() es verdadero, así que pasa directo por el check de “está vacío esto”. Y "!!!".downcase.gsub(/[^a-z0-9]/, "") es "". String vacío.
Acá es donde se pone interesante: un string vacío es una clave de hash perfectamente válida. Así que si un blog de alguna forma termina con dos etiquetas distintas de puro signo de puntuación, ”!!!” y ”???,” digamos, mi método canonical_tags las agrupa bajo la misma clave ("") y conserva solo una, descartando la otra en silencio de la lista de etiquetas conocidas. Peor aún, si después una persona escribe un string de puro signo de puntuación que hace match con la clave vacía de una etiqueta existente, mi lógica de “resolver hacia la grafía canónica” intercambiaría con confianza la etiqueta equivocada, una etiqueta que nadie escribió, sustituida en silencio por la que sí se escribió. Y encima de todo eso: String.prototype.includes("") de JavaScript devuelve true para literalmente cualquier string, así que una búsqueda de puro signo de puntuación haría match y mostraría cada etiqueta conocida como “sugerencia,” anulando por completo el propósito de un autocompletado ordenado por relevancia.
Tres síntomas distintos, una sola causa raíz, y la causa raíz es exactamente la misma categoría de cosa cada vez que un normalizador basado en strip se topa con una entrada medio adversarial: qué pasa en el punto donde la normalización deja el valor completamente vacío. No me había hecho esa pregunta mientras escribía la funcionalidad, porque cada etiqueta con la que probé, homelab, proxmox, self-hosted, tiene letras. El bug solo existe para la clase de entrada que nadie escribe naturalmente mientras prueba su propia funcionalidad.
La revisión de código con múltiples ángulos detectó esto con claridad. Tres ángulos de búsqueda separados convergieron en variaciones de la misma observación, y la pasada de verificación confirmó que los tres síntomas eran genuinamente alcanzables a través de la interfaz real, no teóricos. La solución terminó siendo pequeña: usar como respaldo el nombre crudo de la etiqueta como clave de agrupación cada vez que la normalización produce un string vacío, y agregar una salida explícita por clave vacía antes de que corra la lógica de match/sugerencia en JS. Unas pocas líneas. El bug fue caro de encontrar, barato de arreglar, que es usualmente como salen estas cosas.
Otras cosas que la revisión encontró y que valía la pena arreglar: pegar una lista separada por comas en el nuevo input de chips agregaba el string completo pegado como una sola etiqueta malformada en vez de dividirla, lo cual era una regresión directa contra el campo de texto plano que esta interfaz reemplazó (no se me había ocurrido probar pegar texto, solo escribir). La navegación por teclado tenía un error de desfase por uno, presionar ArrowUp antes de haber presionado ArrowDown se saltaba la última sugerencia de la lista, porque el estado “todavía sin selección” (-1) no se comporta igual bajo aritmética modular que una selección dentro de rango. Y una pequeña que me gustó detectar: ya había escrito una expresión regular de normalización para la sanitización de etiquetas de Dev.to en un issue anterior, y ahí estaba yo, escribiendo la misma expresión regular idéntica de nuevo en un archivo distinto. Dos implementaciones independientes de la misma regla, sin ninguna garantía de que se mantuvieran sincronizadas. Se consolidó en una sola llamada.
Lo que no arreglé: el input oculto que lleva el array de etiquetas entre el nuevo controlador de Stimulus y el controlador de autoguardado existente no tiene un valor de respaldo renderizado por el servidor, a diferencia de cada otro campo de ese formulario. Si el controlador de JS alguna vez fallara al conectarse, ese campo quedaría vacío, y el siguiente autoguardado borraría en silencio las etiquetas de un post. Tanto mi propio razonamiento como una pasada de verificación independiente concluyeron que esto no es alcanzable bajo ningún camino de operación normal en el código tal como se publica hoy, requeriría que Stimulus mismo fallara en registrar un controlador, lo cual ya rompería varias otras cosas en la misma página. Lo documenté en el PR en vez de defenderme de un modo de falla que no tiene camino para ocurrir, ya que la convención de este repo es no escribir código defensivo para cosas que no pueden pasar.
Eso cierra el cluster del editor que ha sido la mayor parte de mi trabajo reciente en blog-manager. Lo siguiente probablemente sea el modo de traducción, que estaba explícitamente esperando a esto y a el flujo de commit antes de arrancar.
Lecturas relacionadas
La misma decisión de botón me costó un bug más grande de lo esperado
Incrustar el flujo de imagen destacada en el editor parecía la opción más chica, hasta que 'reemplazar' se topó con 166 archivos reales que nunca habían pasado por el camino de solo inserción, y una migración sin backfill.
El botón de commit del editor es un botón de deploy
Confirmar un borrador a main despliega el blog automáticamente. En cuanto eso quedó claro, sync vs. async dejó de ser una cuestión de estilo, más el caso especial de afiliado heredado que un validador nuevo casi rompió.
Operaciones globales de tags, y el guard de concurrencia que no lo era
Un renombrado en lote que reportaba éxito mientras fallaban posts, una clave limits_concurrency que nunca compartió un lock de verdad, y 465 tags reales con un duplicado de mayúsculas en vivo para probar contra él.