El hook pre-commit de 08-04 invocaba shellcheck -x sin explicar qué era. Esta lección lo explica. ShellCheck es un programa que lee tus scripts sin ejecutarlos y te dice dónde están los errores: la variable sin comillas de 03-06, el cd sin || de 05-01, el $? mal usado de 03-04, el local x=$(cmd) que se traga el código de salida de 04-02. Es decir, casi todo lo que este curso te ha enseñado a evitar, más un centenar de casos que no hemos visto. Y junto a él, shfmt, el formateador que cierra lo que 08-01 dejó pendiente: que el estilo deje de ser una discusión de equipo y pase a ser una orden.
Contenido
- Qué es el análisis estático y por qué en Bash es imprescindible
- Instalar y ejecutar ShellCheck
- Opciones que cambian el resultado
- Anatomía de un aviso
- Los avisos que más vas a ver
- Tabla resumen de códigos
- Silenciar con criterio
shfmt: el formato deja de opinarse- Integración: editor, hook y CI
- Otras herramientas
- Aplicación: pasar ShellCheck a todo el toolkit
- Qué es el análisis estático y por qué en Bash es imprescindible
Análisis estático es examinar el código sin ejecutarlo, buscando patrones que casi siempre indican un fallo. Existe para todos los lenguajes, pero en Bash es especialmente valioso por una razón concreta: el intérprete no avisa de casi nada hasta que es demasiado tarde.
Bash ejecuta eso sin una sola queja. No hay compilador, ni comprobación de tipos, ni aviso: simplemente borra en dos rutas distintas, ninguna de las cuales es la que querías. Y lo peor es que funciona perfectamente durante meses mientras ningún directorio tenga espacios. Los bugs de Bash no fallan pronto, esperan.
| Método | Cuándo detecta el fallo | Coste |
|---|---|---|
| Ejecutar y ver qué pasa | Cuando se den las condiciones (quizá en producción, de madrugada) | Alto |
| Revisión por un compañero | Si se fija; los fallos de comillas se pasan por alto muy fácilmente | Medio |
| Pruebas con Bats (08-06) | Si el caso está cubierto por una prueba | Medio |
| ShellCheck | Antes de guardar el fichero | Casi cero |
- Instalar y ejecutar ShellCheck
$ sudo apt install shellcheck # Debian/Ubuntu; brew install en macOS
$ shellcheck bin/*.sh lib/comun.shTambién hay imagen de contenedor (koalaman/shellcheck) y binario estático, útiles cuando no puedes instalar paquetes en el servidor. Lo que lo hace automatizable es su código de salida: 0 si no hay avisos, 1 si los hay. Por eso el pre-commit de 08-04 podía limitarse a llamarlo y comprobar el resultado.
- Opciones que cambian el resultado
| Opción | Qué hace | Cuándo usarla |
|---|---|---|
-s bash |
Fuerza el dialecto | Ficheros sin shebang o librerías que se hacen source |
-s sh |
Analiza como POSIX sh | Comprobar portabilidad (08-07) |
-S warning |
Solo esa severidad o superior | Empezar en un script heredado con 200 avisos |
-x |
Sigue los source de otros ficheros |
Casi siempre, con el toolkit |
-P dir |
Ruta donde buscar los ficheros de source |
Cuando el source usa una variable |
-f gcc / -f json |
Salida por líneas / estructurada | Editores, CI, grep, informes |
-e SC2086 |
Excluye ese código en toda la ejecución | Convenciones del equipo |
La opción -x merece explicación, porque sin ella el toolkit da falsos avisos. bin/informe-diario.sh hace source "$DIR_BASE/lib/comun.sh" (05-06); sin -x, ShellCheck no lee esa librería, no sabe que veloz_log_info existe ni que VELOZ_DIR_DATOS está definida, y emite SC1091 y SC2154. Con -x sigue el source y analiza el conjunto:
$ shellcheck -x -P lib bin/informe-diario.sh # formato tty: colores y contexto
In bin/informe-diario.sh line 42:
if [ $uso -gt $UMBRAL ]; then
^--^ SC2086 (info): Double quote to prevent globbing and word splitting.
$ shellcheck -f gcc bin/informe-diario.sh # una linea por aviso
bin/informe-diario.sh:42:10: note: Double quote to prevent globbing... [SC2086]El formato tty es para leer; gcc es el que consumen editores, grep y los sistemas de CI.
- Anatomía de un aviso
In bin/respaldo.sh line 87:
cd $DESTINO
^--------^ SC2164 (warning): Use 'cd ... || exit' in case cd fails.
^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.Cuatro elementos: fichero y línea con la porción exacta subrayada —una misma línea puede acumular varios avisos—; el código SCxxxx, identificador estable que es lo que buscas y lo que silencias; la severidad (error, warning, info, style); y el mensaje, corto y accionable.
Y una cosa que conviene interiorizar: cada código tiene su página de wiki en shellcheck.net/wiki/SC2086, con el problema, ejemplos correctos e incorrectos y las excepciones legítimas. Cuando no entiendas un aviso, esa página es la respuesta; leerla es cómo se aprende Bash de verdad después de un curso.
- Los avisos que más vas a ver
SC2086 — Double quote to prevent globbing and word splitting. El más frecuente con diferencia, y el problema de 03-06: rm $fichero se rompe con espacios y expande comodines. Corrección: rm "$fichero".
SC2046 — Quote this to prevent word splitting. El mismo problema sobre una sustitución de comandos: chmod 600 $(find . -name '*.conf'). La corrección no es entrecomillar —eso pasaría toda la lista como un único argumento— sino find ... -print0 | xargs -0 chmod 600 (05-01).
SC2006 — Use $(...) instead of legacy backticks. Las comillas invertidas no anidan bien y escapan de forma extraña; la corrección es mecánica.
SC2164 — Use cd ... || exit. Si el cd falla, el script sigue en el directorio anterior y las órdenes destructivas siguientes actúan donde no deben. Corrección: cd "$dir" || veloz_morir 1 "no puedo entrar en $dir".
SC2181 — Check exit code directly with if cmd;, not indirectly with $?. Es 03-04: if grep -q ERROR "$log"; then en lugar de comprobar $? en la línea siguiente. Además de más claro, evita el error clásico de intercalar un echo de depuración que cambia $?.
SC2148 — add a shebang. Falta el #!/usr/bin/env bash de 03-01. En librerías que solo se hacen source no procede un shebang, y ahí la solución es -s bash o la directiva # shellcheck shell=bash.
SC2034 — variable appears unused. Casi siempre es una variable muerta de un refactor; a veces, un falso positivo cuando la consume otro fichero mediante source o un local -n.
SC2155 — Declare and assign separately to avoid masking return values. Sutil y muy importante (04-02):
local fecha=$(date -d "$entrada" +%F) # el codigo de salida es el de "local": 0 SIEMPRE
local fecha # BIEN: dos pasos
fecha=$(date -d "$entrada" +%F) || return 1Con set -e esto pesa mucho: la primera versión no aborta aunque date falle.
SC2016 — Expressions don't expand in single quotes. Suele ser un falso positivo en awk '{print $3}', donde las comillas simples son exactamente lo que quieres; se silencia con una directiva. SC2154 (variable referenciada sin asignar) y SC1090/SC1091 (source no seguible) son típicos de librerías y se resuelven con -x -P lib o con # shellcheck source=lib/comun.sh justo encima del source, que es preferible porque queda documentado en el propio fichero.
- Tabla resumen de códigos
| Código | Problema | Corrección | Lección |
|---|---|---|---|
| SC2086 | Expansión sin comillas | "$var" |
03-06 |
| SC2046 | $(...) sin comillas en argumentos |
-print0 + xargs -0 |
05-01 |
| SC2006 | Comillas invertidas | $(...) |
03-06 |
| SC2164 | cd sin control de error |
cd "$d" || veloz_morir ... |
05-03 |
| SC2181 | $? en un if posterior |
if cmd; then |
03-04 |
| SC2148 | Falta shebang | #!/usr/bin/env bash |
03-01 |
| SC2034 | Variable sin usar | Borrarla o justificar | 03-02 |
| SC2155 | local x=$(cmd) enmascara el código |
Declarar y asignar por separado | 04-02 |
| SC2016 | $ dentro de comillas simples |
Suele ser correcto: silenciar | 03-06 |
| SC2154 | Variable no asignada en el fichero | -x para seguir el source |
05-06 |
| SC1090/91 | source no seguible |
-x -P o # shellcheck source= |
05-06 |
| SC2115 | rm -rf "$d/" con $d posiblemente vacío |
${d:?} |
03-06 |
| SC2207 | arr=( $(cmd) ) |
mapfile -t arr < <(cmd) |
04-03 |
- Silenciar con criterio
A veces ShellCheck se equivoca, o el aviso es correcto pero tú quieres ese comportamiento. La directiva disable tiene tres alcances:
#!/usr/bin/env bash
# shellcheck disable=SC2034 <- alcance de fichero, antes de todo codigo
# El desglose de opciones DEBE dividirse en palabras: es una lista de argumentos.
# shellcheck disable=SC2086 <- alcance de la orden siguiente
curl $OPCIONES_CURL "$url"
# shellcheck source=lib/comun.sh <- indica la ruta del source
source "$DIR_BASE/lib/comun.sh"Y un .shellcheckrc en la raíz para las decisiones de equipo, con source-path=lib y external-sources=true. La norma que debe imponerse: toda directiva disable va acompañada de un comentario que la justifique, en la línea inmediatamente anterior. Un disable sin justificar es peor que el aviso original: el aviso al menos era visible y alguien podía investigarlo, mientras que el disable mudo lo entierra y hace creer que el código está revisado. Si silencias algo porque no lo entiendes, has ocultado un bug, no lo has corregido; lee la wiki primero, porque en la inmensa mayoría de los casos el aviso tiene razón.
shfmt: el formato deja de opinarse
shfmt: el formato deja de opinarseEn 08-01 acordamos un estilo —cuatro espacios, then en la misma línea— y quedó pendiente automatizarlo. shfmt es el formateador de Bash, instalable desde el gestor de paquetes o con go install mvdan.cc/sh/v3/cmd/shfmt@latest.
| Opción | Efecto |
|---|---|
-i 4 |
Indentar con 4 espacios (-i 0 para tabuladores) |
-ci |
Indentar los case con un nivel extra |
-bn |
Operadores binarios (&&, ||) al principio de la línea siguiente |
-d |
Muestra el diff y sale con 1 si el fichero no está formateado |
-w / -l |
Reescribe el fichero / lista los mal formateados |
Con un .editorconfig en la raíz, shfmt toma de ahí la configuración y no hace falta repetir las opciones. El valor en un equipo es doble: elimina la discusión sobre estilo —lo decide el programa— y hace que los diffs de Git contengan solo cambios reales, sin ruido de reindentaciones. Cuidado con una cosa: formatea todo el proyecto una vez, en un commit propio titulado «Formatear el toolkit con shfmt»; mezclar formato y lógica en el mismo commit hace la revisión imposible.
- Integración: editor, hook y CI
Tres niveles, del más inmediato al más definitivo. En el editor es donde más tiempo se ahorra, porque el aviso aparece mientras escribes: VS Code tiene timonwong.shellcheck y mkhl.shfmt; Vim y Neovim lo integran con ALE o el LSP bash-language-server. En el hook pre-commit (08-04) se impide que entre código con avisos aunque el compañero no tenga la extensión. Y en integración continua, la única barrera que nadie puede saltarse con --no-verify:
# .github/workflows/shell.yml
name: shell
on: [push, pull_request]
jobs:
analisis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ShellCheck
run: |
sudo apt-get update && sudo apt-get install -y shellcheck
shellcheck -x -P lib -S warning bin/*.sh lib/*.sh
- uses: mfinelli/setup-shfmt@v3
- run: shfmt -i 4 -ci -d bin/ lib/Ambos pasos aprovechan el código de salida: ShellCheck devuelve 1 si hay avisos y shfmt -d devuelve 1 si algo no está formateado, así que el trabajo falla solo. -S warning es una decisión consciente: exigimos warning y error, y dejamos info y style como recomendación para no bloquear a nadie por un detalle de forma.
- Otras herramientas
checkbashisms(paquetedevscripts): detecta construcciones de Bash en scripts que declaran#!/bin/sh. Es la herramienta central de la portabilidad y reaparece en 08-07.shellharden: reescribe el código añadiendo las comillas que faltan; potente y peligroso a partes iguales.bashate: estilo, más opinado sobre formato que sobre corrección.bash -n: no es análisis estático, solo comprobación de sintaxis (05-03), pero es gratis y universal. En el hook va antes de ShellCheck, porque un fichero con unfide más ni siquiera se puede analizar.
- Aplicación: pasar ShellCheck a todo el toolkit
$ shellcheck -x -P lib -f gcc bin/*.sh lib/comun.sh | tee /tmp/avisos.txt | wc -l
47
$ grep -oE 'SC[0-9]+' /tmp/avisos.txt | sort | uniq -c | sort -rn
19 SC2086
7 SC2046
5 SC2155
4 SC2181
3 SC2164
3 SC2034
2 SC2006
2 SC2115
1 SC2207
1 SC2016Cuarenta y siete avisos en código que ya habíamos revisado dos veces. Esa cifra es la lección entera: la revisión humana no ve las comillas que faltan.
| Grupo | Avisos | Acción |
|---|---|---|
| Corregir ya (riesgo real) | SC2115 (2), SC2164 (3), SC2155 (5) | ${d:?}, cd || morir, separar declaración y asignación |
| Corregir en bloque (mecánico) | SC2086 (19), SC2046 (7), SC2006 (2) | Comillas, -print0/xargs -0, $(...) |
| Limpiar | SC2034 (3), SC2181 (4), SC2207 (1) | Borrar variables muertas, if cmd;, mapfile |
| Silenciar justificado | SC2016 (1) | $3 dentro del programa de awk: directiva con comentario |
Los dos SC2115 eran el hallazgo grave: un rm -rf "$VELOZ_DIR_RESPALDO/$fecha" en respaldo.sh donde, si $fecha quedaba vacía por un date fallido, la orden se convertía en rm -rf "/srv/veloz/respaldos/". La corrección es de tres caracteres y evita un desastre:
Con ${var:?} (03-06) el script aborta con un mensaje claro si la variable está vacía, en lugar de borrar la raíz del respaldo. Tras aplicar todo y formatear con shfmt -i 4 -ci -w, shellcheck -x -P lib bin/*.sh lib/comun.sh pasa sin avisos.
Errores Comunes y Consejos
- Ejecutar ShellCheck sin
-xen un proyecto con librerías. Genera decenas de SC1091 y SC2154 falsos que sepultan los avisos reales. - Silenciar en masa para «dejarlo limpio». Un
.shellcheckrccon diezdisableno es código revisado, es código con los avisos apagados. - Creer que sin avisos el script es correcto. ShellCheck no sabe si tu lógica de negocio está bien; para eso están las pruebas (08-06).
- Ignorar los
infopor sistema. SC2086 es de severidadinfoy causa la mitad de los bugs de Bash en producción. - Consejo: empieza por
-S error, luegowarning, luego todo. En un script heredado con 200 avisos, ir por severidades hace el trabajo abarcable. - Consejo:
shellcheck.netpermite pegar un fragmento y ver los avisos sin instalar nada; nunca con código que contenga datos internos.
Ejercicios
Ejercicio 1. Ejecuta mentalmente ShellCheck sobre este fragmento: enumera los avisos con su código y reescríbelo corregido.
cd $DIR_RESPALDO
ficheros=`find . -name "*.tar.gz" -mtime +30`
for f in $ficheros; do
rm -f $f
done
grep -q ERROR $LOG
if [ $? -eq 0 ]; then
resumen=$(wc -l < $LOG)
echo "hay errores: $resumen"
fiEjercicio 2. ShellCheck avisa de SC2086 en veloz_log_info $mensaje dentro de lib/comun.sh. Explica por qué es un fallo real y no un falso positivo, con un ejemplo concreto de Veloz Envíos.
Ejercicio 3. Escribe el paso de CI que exija a la vez ShellCheck sin avisos de severidad warning o superior y formato shfmt correcto, fallando el trabajo si cualquiera de las dos cosas no se cumple.
Soluciones
Solución 1. Avisos: SC2164 (cd sin control), SC2086 (en $DIR_RESPALDO, $f y $LOG dos veces), SC2006 (comillas invertidas), SC2044/SC2207 (iterar sobre la salida de find) y SC2181 ($? en el if).
cd "$DIR_RESPALDO" || veloz_morir 1 "no puedo entrar en $DIR_RESPALDO"
find . -name '*.tar.gz' -mtime +30 -print0 | xargs -0 -r rm -f --
if grep -q ERROR "$LOG"; then
resumen=$(wc -l < "$LOG")
printf 'hay errores: %s\n' "$resumen"
fiEl bucle desaparece: find -print0 con xargs -0 (05-01) resuelve a la vez la división de palabras y los nombres con espacios, y -r evita ejecutar rm si no hay ficheros.
Solución 2. Es un fallo real porque el mensaje se construye con datos externos. Con mensaje="incidencia en Palma de Mallorca", la función recibe cinco argumentos en lugar de uno, y si internamente usa $1 el log registrará solo «incidencia». Peor: si el mensaje procede de una línea de acceso.log (08-03) y contiene un *, el globbing lo sustituye por la lista de ficheros del directorio actual y el log queda inservible. La corrección es veloz_log_info "$mensaje", y dentro de la función usar "$*" o "$@" de forma consciente (03-05).
Solución 3.
- name: Analisis estatico y formato
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y shellcheck
shellcheck -x -P lib -S warning bin/*.sh lib/*.sh
shfmt -i 4 -ci -d bin/ lib/set -euo pipefail (05-03) detiene el paso en la primera orden que falle; ShellCheck devuelve 1 si encuentra avisos de warning o superior y shfmt -d devuelve 1 si algún fichero no está formateado, así que ambas condiciones se traducen directamente en el resultado del trabajo sin comprobar nada a mano.
Conclusión
ShellCheck es la herramienta con mejor relación entre esfuerzo y beneficio de todo el módulo: analiza el script sin ejecutarlo, tarda menos de un segundo y encuentra exactamente los fallos que Bash nunca señalará, porque el intérprete acepta sin protestar un rm -rf $dir/* que funcionará bien hasta el día en que aparezca un espacio. Se ejecuta sobre los ficheros y devuelve 0 o 1, lo que lo hace automatizable; -x con -P lib es imprescindible en el toolkit para que siga los source de la librería y no llene la salida de SC1091 y SC2154 falsos, -S warning acota la severidad en proyectos heredados y -f gcc produce la salida que consumen editores y CI. Cada aviso trae fichero, línea, fragmento subrayado, severidad y un código SCxxxx con su página de wiki, que es donde de verdad se aprende. Los que más verás ya los conoces por su lección: SC2086 y SC2046 son las comillas de 03-06, SC2164 es el cd sin ||, SC2181 es el $? que debía ser if cmd;, SC2155 es el local x=$(cmd) que enmascara el código de salida de 04-02, SC2148 el shebang de 03-01 y SC1090/SC1091 el source de 05-06. Silenciar es legítimo cuando el aviso es un falso positivo, pero toda directiva disable lleva un comentario que la justifica, porque un disable mudo entierra un bug y aparenta código revisado. shfmt -i 4 -ci -w cierra lo prometido en 08-01 convirtiendo el estilo en una orden en lugar de una discusión, con la ventaja de que los diffs de Git dejan de tener ruido —siempre que el formateo inicial vaya en su propio commit—. La integración se hace en tres niveles: el editor, donde el aviso llega antes de guardar; el hook pre-commit de 08-04; y CI, la única barrera que no se puede saltar. La aplicación al toolkit lo demuestra: 47 avisos en código ya revisado dos veces por humanos, entre ellos un rm -rf que habría borrado la raíz del directorio de respaldos si un date fallaba.
Ahora el toolkit está limpio de errores de forma. Pero ShellCheck no sabe si veloz_porcentaje 0 0 devuelve algo sensato, ni si respaldo.sh conserva de verdad los treinta días de retención, ni si el refactor de rendimiento de 08-02 cambió el contenido del informe. Eso solo lo puede decir una prueba que ejecute el código y compare el resultado con lo esperado. La lección 08-06 introduce Bats: qué merece la pena probar en un script de operaciones, cómo aislar la prueba del entorno con directorios temporales y datos ficticios, cómo sustituir un comando externo por un doble para probar veloz_api_get sin API y respaldo.sh sin tocar el disco, y cómo escribir tests/comun.bats con pruebas reales de las funciones de la librería.
Curso de Programación en Bash
Módulo 1: Introducción a Bash
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
