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

  1. Qué es el análisis estático y por qué en Bash es imprescindible
  2. Instalar y ejecutar ShellCheck
  3. Opciones que cambian el resultado
  4. Anatomía de un aviso
  5. Los avisos que más vas a ver
  6. Tabla resumen de códigos
  7. Silenciar con criterio
  8. shfmt: el formato deja de opinarse
  9. Integración: editor, hook y CI
  10. Otras herramientas
  11. Aplicación: pasar ShellCheck a todo el toolkit

  1. 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.

directorio="/srv/veloz/datos de julio"
rm -rf $directorio/*.tmp

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

  1. Instalar y ejecutar ShellCheck

$ sudo apt install shellcheck          # Debian/Ubuntu; brew install en macOS
$ shellcheck bin/*.sh lib/comun.sh

Tambié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.

  1. 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.

  1. 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.

  1. 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 1

Con 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.

  1. 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

  1. 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.

  1. shfmt: el formato deja de opinarse

En 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
$ shfmt -i 4 -ci -d bin/vigilante.sh     # ver que cambiaria
$ shfmt -i 4 -ci -w bin/ lib/            # aplicarlo

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.

  1. 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.

  1. Otras herramientas

  • checkbashisms (paquete devscripts): 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 un fi de más ni siquiera se puede analizar.

  1. 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 SC2016

Cuarenta 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:

rm -rf -- "${VELOZ_DIR_RESPALDO:?}/${fecha:?}"

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 -x en un proyecto con librerías. Genera decenas de SC1091 y SC2154 falsos que sepultan los avisos reales.
  • Silenciar en masa para «dejarlo limpio». Un .shellcheckrc con diez disable no 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 info por sistema. SC2086 es de severidad info y causa la mitad de los bugs de Bash en producción.
  • Consejo: empieza por -S error, luego warning, luego todo. En un script heredado con 200 avisos, ir por severidades hace el trabajo abarcable.
  • Consejo: shellcheck.net permite 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"
fi

Ejercicio 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"
fi

El 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

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados