@bi-analytics/arness-back 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +156 -2
  3. package/bin/arness.mjs +710 -0
  4. package/harness/AGENTS.md +238 -0
  5. package/harness/agents/implementador.md +42 -0
  6. package/harness/agents/lider.md +72 -0
  7. package/harness/agents/revisor.md +62 -0
  8. package/harness/decisiones/README.md +17 -0
  9. package/harness/decisiones/_plantilla.md +24 -0
  10. package/harness/init.sh +743 -0
  11. package/harness/playbooks/add-database.md +16 -0
  12. package/harness/playbooks/new-endpoint.md +88 -0
  13. package/harness/playbooks/new-module.md +21 -0
  14. package/harness/playbooks/new-monitor-widget.md +31 -0
  15. package/harness/playbooks/review.md +41 -0
  16. package/harness/playbooks/update-node-lts.md +46 -0
  17. package/harness/reference/configuracion.md +111 -0
  18. package/harness/reference/contrato-http.md +169 -0
  19. package/harness/reference/novedades.json +158 -0
  20. package/harness/reference/progress-plantilla.md +50 -0
  21. package/harness/skills/commit-hygiene/SKILL.md +137 -0
  22. package/harness/skills/nest-base/SKILL.md +168 -0
  23. package/harness/skills/nest-base/templates/config/auth.spec.ts +32 -0
  24. package/harness/skills/nest-base/templates/config/auth.ts +80 -0
  25. package/harness/skills/nest-base/templates/config/configuration.spec.ts +85 -0
  26. package/harness/skills/nest-base/templates/config/configuration.ts +137 -0
  27. package/harness/skills/nest-base/templates/cors/cors.spec.ts +53 -0
  28. package/harness/skills/nest-base/templates/cors/cors.ts +75 -0
  29. package/harness/skills/nest-base/templates/docs/EVOLUTIVO.md +27 -0
  30. package/harness/skills/nest-base/templates/docs/PENDIENTES.md +20 -0
  31. package/harness/skills/nest-base/templates/docs/TECNICO.md +43 -0
  32. package/harness/skills/nest-base/templates/e2e/cors.e2e-spec.ts +60 -0
  33. package/harness/skills/nest-base/templates/e2e/monitor.e2e-spec.ts +151 -0
  34. package/harness/skills/nest-base/templates/editorconfig +12 -0
  35. package/harness/skills/nest-base/templates/eslint.boundaries.mjs +65 -0
  36. package/harness/skills/nest-base/templates/eslint.config.mjs +82 -0
  37. package/harness/skills/nest-base/templates/logging/logger-options.spec.ts +57 -0
  38. package/harness/skills/nest-base/templates/logging/logger-options.ts +85 -0
  39. package/harness/skills/nest-base/templates/monitor/monitor-login-redirect.filter.ts +16 -0
  40. package/harness/skills/nest-base/templates/monitor/monitor-login.dto.ts +8 -0
  41. package/harness/skills/nest-base/templates/monitor/monitor-page.spec.ts +41 -0
  42. package/harness/skills/nest-base/templates/monitor/monitor-page.ts +190 -0
  43. package/harness/skills/nest-base/templates/monitor/monitor-session.guard.spec.ts +46 -0
  44. package/harness/skills/nest-base/templates/monitor/monitor-session.guard.ts +36 -0
  45. package/harness/skills/nest-base/templates/monitor/monitor-session.service.spec.ts +57 -0
  46. package/harness/skills/nest-base/templates/monitor/monitor-session.service.ts +77 -0
  47. package/harness/skills/nest-base/templates/monitor/monitor-widget.interface.ts +18 -0
  48. package/harness/skills/nest-base/templates/monitor/monitor.constants.ts +25 -0
  49. package/harness/skills/nest-base/templates/monitor/monitor.controller.ts +84 -0
  50. package/harness/skills/nest-base/templates/monitor/monitor.decorators.ts +11 -0
  51. package/harness/skills/nest-base/templates/monitor/monitor.module.ts +30 -0
  52. package/harness/skills/nest-base/templates/monitor/monitor.service.spec.ts +55 -0
  53. package/harness/skills/nest-base/templates/monitor/monitor.service.ts +49 -0
  54. package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.spec.ts +51 -0
  55. package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.ts +53 -0
  56. package/harness/skills/nest-base/templates/prettierignore +6 -0
  57. package/harness/skills/nest-base/templates/prettierrc.json +8 -0
  58. package/harness/skills/nest-base/templates/security/login-attempts.spec.ts +48 -0
  59. package/harness/skills/nest-base/templates/security/login-attempts.ts +90 -0
  60. package/harness/skills/nest-base/templates/security/security-headers.spec.ts +27 -0
  61. package/harness/skills/nest-base/templates/security/security-headers.ts +21 -0
  62. package/harness/skills/nest-base/templates/security/security.module.ts +20 -0
  63. package/harness/skills/nest-base/templates/security/session-lifetime.spec.ts +21 -0
  64. package/harness/skills/nest-base/templates/security/session-lifetime.ts +28 -0
  65. package/package.json +44 -3
@@ -0,0 +1,743 @@
1
+ #!/usr/bin/env bash
2
+ # init.sh — Revisa el estado del proyecto antes y después de cualquier cambio (AGENTS.md §0).
3
+ #
4
+ # Uso (desde cualquier carpeta): ./harness/init.sh [opciones]
5
+ # --strict Los hallazgos fallan (exit 1) en lugar de quedar como advertencias
6
+ # --json Imprime el resultado como JSON en stdout (el detalle legible va a stderr)
7
+ # --quick Solo harness, estructura y toolchain (sin lint, build, tests ni health)
8
+ # --e2e Además ejecuta pnpm test:e2e
9
+ # --no-health No consulta /health
10
+ # --install Si faltan node_modules, ejecuta pnpm install --frozen-lockfile
11
+ # -h, --help Muestra esta ayuda
12
+ #
13
+ # Por defecto los hallazgos son advertencias y el script termina en 0: el proyecto sigue
14
+ # funcionando. Única excepción: .env o .env.local versionado en git (fuga de secretos).
15
+ #
16
+ # Configuración del repo (opcional, sync nunca la toca): harness/arness.config.json
17
+ # { "strict": false,
18
+ # "ignore": { "<id>": "<motivo>" }, silencia un chequeo
19
+ # "required": ["<id>"], ese chequeo falla aunque no haya --strict
20
+ # "declined": { "<novedad>": "<motivo>" } } novedad que el repo no adopta (lo lee arness doctor)
21
+ #
22
+ # Variables opcionales:
23
+ # HEALTH_URL URL del health (default http://localhost:$PORT/health)
24
+ # HEALTH_TIMEOUT_S Segundos de espera al arrancar la app (default 30)
25
+ # NODE_LTS_VERSION Última LTS de Node con la que se compara .nvmrc (default: se consulta
26
+ # nodejs.org; sin red se omite). "off" desactiva la comparación
27
+ #
28
+ # Salida: 0 = operativo (puede haber advertencias) · 1 = fallo crítico o --strict con hallazgos · 2 = uso incorrecto
29
+
30
+ set -o pipefail
31
+
32
+ # El script vive en harness/, pero verifica desde la raíz del proyecto.
33
+ HARNESS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
+ ROOT="$(dirname "$HARNESS_DIR")"
35
+ cd "$ROOT" || exit 2
36
+ H=harness
37
+ CONFIG="$H/arness.config.json"
38
+
39
+ STRICT=0; JSON=0; QUICK=0; E2E=0; HEALTH=1; INSTALL=0
40
+ for arg in "$@"; do
41
+ case "$arg" in
42
+ --strict) STRICT=1 ;;
43
+ --json) JSON=1 ;;
44
+ --quick) QUICK=1 ;;
45
+ --e2e) E2E=1 ;;
46
+ --no-health) HEALTH=0 ;;
47
+ --install) INSTALL=1 ;;
48
+ -h|--help) awk 'NR>1 && /^#/{sub(/^# ?/,""); print; next} NR>1{exit}' "$0"; exit 0 ;;
49
+ *) echo "Opción desconocida: $arg (usa --help)" >&2; exit 2 ;;
50
+ esac
51
+ done
52
+
53
+ # Con --json, el detalle legible va a stderr y stdout queda solo para el JSON (fd 3).
54
+ if [ "$JSON" -eq 1 ]; then exec 3>&1 1>&2; fi
55
+
56
+ if [ -t 1 ] && [ "$JSON" -eq 0 ]; then
57
+ RED=$'\033[31m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; BLUE=$'\033[34m'; BOLD=$'\033[1m'; RESET=$'\033[0m'
58
+ else
59
+ RED=''; GREEN=''; YELLOW=''; BLUE=''; BOLD=''; RESET=''
60
+ fi
61
+
62
+ CRITS=0; FAILS=0; WARNS=0; IGNORED=0
63
+ SECTION=''
64
+ LOG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/init-sh.XXXXXX")"
65
+ ISSUES="$LOG_DIR/issues.tsv"; : >"$ISSUES"
66
+ APP_PID=''
67
+
68
+ cleanup() {
69
+ if [ -n "$APP_PID" ] && kill -0 "$APP_PID" 2>/dev/null; then
70
+ kill "$APP_PID" 2>/dev/null
71
+ wait "$APP_PID" 2>/dev/null
72
+ fi
73
+ rm -rf "$LOG_DIR"
74
+ }
75
+ trap cleanup EXIT INT TERM
76
+
77
+ # ── Configuración del repo ──────────────────────────────────────────────────
78
+ CFG_IGNORE=' '; CFG_REQUIRED=' '; CFG_ERROR=''
79
+ if [ -f "$CONFIG" ]; then
80
+ cfg="$(node -e '
81
+ let c; try { c = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); }
82
+ catch (e) { console.log("error\t" + e.message); process.exit(0); }
83
+ if (c.strict === true) console.log("strict");
84
+ for (const [id, why] of Object.entries(c.ignore || {})) console.log(["ignore", id, String(why || "").trim()].join("\t"));
85
+ for (const id of c.required || []) console.log("required\t" + id);
86
+ ' "$CONFIG" 2>&1)"
87
+ while IFS="$(printf '\t')" read -r kind id why; do
88
+ case "$kind" in
89
+ strict) STRICT=1 ;;
90
+ ignore) CFG_IGNORE="$CFG_IGNORE$id "; [ -z "$why" ] && CFG_ERROR="${CFG_ERROR}ignore.$id sin motivo; " ;;
91
+ required) CFG_REQUIRED="$CFG_REQUIRED$id " ;;
92
+ error) CFG_ERROR="${CFG_ERROR}JSON inválido: $id; " ;;
93
+ '') ;;
94
+ *) CFG_ERROR="${CFG_ERROR}$kind; " ;;
95
+ esac
96
+ done <<EOF
97
+ $cfg
98
+ EOF
99
+ fi
100
+
101
+ # ── Salida ──────────────────────────────────────────────────────────────────
102
+ record() { printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$SECTION" "$3" >>"$ISSUES"; }
103
+ section() { SECTION="$1"; printf '\n%s%s▸ %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET"; }
104
+ ok() { printf ' %s✔%s %s\n' "$GREEN" "$RESET" "$1"; }
105
+ info() { printf ' %sℹ%s %s\n' "$BLUE" "$RESET" "$1"; }
106
+ # Aviso informativo: nunca falla, ni siquiera con --strict.
107
+ warn() { printf ' %s⚠%s %s\n' "$YELLOW" "$RESET" "$1"; WARNS=$((WARNS + 1)); record warning "${2:--}" "$1"; }
108
+
109
+ # Hallazgo con id: advertencia por defecto; fallo con --strict o si el repo lo marca como required.
110
+ issue() { # id mensaje
111
+ local id="$1" msg="$2"
112
+ case "$CFG_REQUIRED" in *" $id "*) ;; *)
113
+ case "$CFG_IGNORE" in *" $id "*)
114
+ printf ' %s·%s %s %s(silenciado: %s)%s\n' "$BLUE" "$RESET" "$msg" "$BLUE" "$id" "$RESET"
115
+ IGNORED=$((IGNORED + 1)); record ignored "$id" "$msg"; return ;;
116
+ esac ;;
117
+ esac
118
+ if [ "$STRICT" -eq 1 ] || case "$CFG_REQUIRED" in *" $id "*) true ;; *) false ;; esac; then
119
+ printf ' %s✖%s %s %s[%s]%s\n' "$RED" "$RESET" "$msg" "$BLUE" "$id" "$RESET"
120
+ FAILS=$((FAILS + 1)); record failure "$id" "$msg"
121
+ else
122
+ printf ' %s⚠%s %s %s[%s]%s\n' "$YELLOW" "$RESET" "$msg" "$BLUE" "$id" "$RESET"
123
+ WARNS=$((WARNS + 1)); record warning "$id" "$msg"
124
+ fi
125
+ }
126
+
127
+ # Fallo crítico: siempre exit 1 y no se puede silenciar.
128
+ crit() { # id mensaje
129
+ printf ' %s✖ CRÍTICO:%s %s %s[%s]%s\n' "$RED$BOLD" "$RESET" "$2" "$BLUE" "$1" "$RESET"
130
+ CRITS=$((CRITS + 1)); record critical "$1" "$2"
131
+ }
132
+
133
+ need_file() { if [ -f "$2" ]; then ok "$2"; else issue "$1" "Falta el archivo $2"; fi; }
134
+ need_dir() { if [ -d "$2" ]; then ok "$2/"; else issue "$1" "Falta el directorio $2/"; fi; }
135
+
136
+ # Ejecuta un comando, guarda su salida y muestra el final solo si falla.
137
+ run_step() { # id etiqueta comando...
138
+ local id="$1" label="$2"; shift 2
139
+ local log="$LOG_DIR/$(echo "$label" | tr -c 'a-zA-Z0-9' '_').log"
140
+ if "$@" >"$log" 2>&1; then
141
+ ok "$label"
142
+ else
143
+ issue "$id" "$label ($*)"
144
+ tail -n 25 "$log" | sed 's/^/ │ /'
145
+ fi
146
+ }
147
+
148
+ if [ -f "$CONFIG" ]; then
149
+ section "Configuración"
150
+ info "$CONFIG: modo $([ "$STRICT" -eq 1 ] && echo estricto || echo advertencias)"
151
+ [ -n "$CFG_ERROR" ] && warn "$CONFIG: $CFG_ERROR" config.invalid
152
+ fi
153
+
154
+ # ─────────────────────────────────────────────────────────────────────────────
155
+ section "Harness"
156
+
157
+ for f in $H/AGENTS.md $H/init.sh $H/decisiones/README.md $H/decisiones/_plantilla.md \
158
+ $H/skills/nest-base/SKILL.md $H/reference/configuracion.md $H/reference/contrato-http.md \
159
+ $H/reference/progress-plantilla.md CLAUDE.md GEMINI.md .cursor/rules/agents.mdc; do
160
+ need_file harness.files "$f"
161
+ done
162
+
163
+ if [ -f $H/AGENTS.md ]; then
164
+ missing_sections=''
165
+ for n in 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17; do
166
+ grep -q "^## $n\. " $H/AGENTS.md || missing_sections="$missing_sections §$n"
167
+ done
168
+ if [ -z "$missing_sections" ]; then ok "$H/AGENTS.md tiene las secciones §0–§17"
169
+ else issue harness.agents-md "$H/AGENTS.md incompleto; faltan:$missing_sections"; fi
170
+
171
+ # Todo archivo del harness referenciado en AGENTS.md debe existir.
172
+ for ref in $(grep -oE "$H/(playbooks|reference|skills|agents|decisiones)/[A-Za-z0-9_./-]+\.md" $H/AGENTS.md | grep -v NNNN | sort -u); do
173
+ [ -f "$ref" ] || issue harness.refs "AGENTS.md referencia $ref, pero no existe"
174
+ done
175
+ fi
176
+
177
+ # Puentes de la raíz: solo enlaces e imports hacia harness/.
178
+ # Se acepta un enlace simbólico o, donde no hay symlinks (arness --copy), una copia idéntica.
179
+ check_link() { # enlace destino-relativo
180
+ local target_abs; target_abs="$(dirname "$1")/$2"
181
+ if [ -L "$1" ] && [ "$(readlink "$1")" = "$2" ] && [ -e "$1" ]; then ok "$1 → $2"
182
+ elif [ -e "$1" ] && [ ! -L "$1" ] && diff -rq "$1" "$target_abs" >/dev/null 2>&1; then ok "$1 = copia de $2"
183
+ else issue harness.bridges "$1 debe ser un enlace simbólico (o copia idéntica) a $2"; fi
184
+ }
185
+ check_link AGENTS.md "$H/AGENTS.md"
186
+ check_link .claude/agents "../$H/agents"
187
+ check_link .claude/skills "../$H/skills"
188
+ check_link .agents/skills "../$H/skills"
189
+
190
+ grep -q "^@$H/AGENTS.md" CLAUDE.md 2>/dev/null && ok "CLAUDE.md importa $H/AGENTS.md" || issue harness.bridges "CLAUDE.md no importa @$H/AGENTS.md"
191
+ grep -q "^@\./$H/AGENTS.md" GEMINI.md 2>/dev/null && ok "GEMINI.md importa $H/AGENTS.md" || issue harness.bridges "GEMINI.md no importa @./$H/AGENTS.md"
192
+ grep -q "$H/AGENTS.md" .cursor/rules/agents.mdc 2>/dev/null && ok "Regla de Cursor apunta a $H/AGENTS.md" || issue harness.bridges "La regla de Cursor no apunta a $H/AGENTS.md"
193
+
194
+ agents_before=$((FAILS + WARNS))
195
+ for ag in lider implementador revisor; do
196
+ f="$H/agents/$ag.md"
197
+ if [ ! -f "$f" ]; then issue harness.agents "Falta el agente $f"; continue; fi
198
+ for key in name description tools model; do
199
+ awk 'NR==1 && $0!="---"{exit 1} NR>1 && $0=="---"{exit} NR>1{print}' "$f" | grep -q "^$key:" \
200
+ || issue harness.agents "$f: falta '$key' en el frontmatter"
201
+ done
202
+ done
203
+ [ "$((FAILS + WARNS))" -eq "$agents_before" ] && ok "Agentes lider, implementador y revisor definidos"
204
+
205
+ # ─────────────────────────────────────────────────────────────────────────────
206
+ section "Decisiones"
207
+
208
+ decision_count=0
209
+ for d in $H/decisiones/[0-9][0-9][0-9][0-9]-*.md; do
210
+ [ -f "$d" ] || continue
211
+ decision_count=$((decision_count + 1))
212
+ name="$(basename "$d")"
213
+ estado="$(grep -m1 '^- \*\*Estado:\*\*' "$d" | sed 's/^- \*\*Estado:\*\* *//')"
214
+ if ! echo "$estado" | grep -Eq '^(Propuesta|Aceptada|Reemplazada por [0-9]{4}|Obsoleta)$'; then
215
+ issue decisions.state "$name: estado inválido o ausente ('$estado')"
216
+ elif [ "$estado" = "Propuesta" ]; then
217
+ warn "$name está en Propuesta: pendiente de aceptación humana" decisions.proposed
218
+ fi
219
+ grep -q "($name)" $H/decisiones/README.md 2>/dev/null || issue decisions.index "$name no está en el índice de $H/decisiones/README.md"
220
+ done
221
+ dupes="$(ls $H/decisiones 2>/dev/null | grep -E '^[0-9]{4}-' | cut -c1-4 | sort | uniq -d | tr '\n' ' ')"
222
+ [ -n "$dupes" ] && issue decisions.dupes "Números de decisión duplicados: $dupes"
223
+ ok "$decision_count decisión(es) revisada(s)"
224
+
225
+ # ─────────────────────────────────────────────────────────────────────────────
226
+ section "Progress"
227
+
228
+ PROGRESS="$H/progress.md"
229
+ if [ -f "$PROGRESS" ]; then
230
+ p_estado="$(grep -m1 '^- \*\*Estado:\*\*' "$PROGRESS" | sed 's/^- \*\*Estado:\*\* *//')"
231
+ case "$p_estado" in
232
+ TERMINADO) ok "$PROGRESS: TERMINADO (el líder lo reinicia en la próxima tarea)" ;;
233
+ "EN CURSO") warn "$PROGRESS: hay trabajo EN CURSO; confirma con el humano si se retoma" progress.open ;;
234
+ BLOQUEADO) warn "$PROGRESS: BLOQUEADO; revisa 'Bloqueos y preguntas abiertas' antes de continuar" progress.open ;;
235
+ *) issue progress.state "$PROGRESS: estado inválido o ausente ('$p_estado'); usa EN CURSO | BLOQUEADO | TERMINADO" ;;
236
+ esac
237
+ p_lines="$(wc -l < "$PROGRESS" | tr -d ' ')"
238
+ [ "$p_lines" -gt 200 ] && warn "$PROGRESS tiene $p_lines líneas; el líder debe resumir las tareas cerradas" progress.size
239
+ else
240
+ info "Sin $PROGRESS: el líder lo crea desde la plantilla al iniciar una tarea"
241
+ fi
242
+
243
+ # ─────────────────────────────────────────────────────────────────────────────
244
+ section "Git y secretos"
245
+
246
+ if git rev-parse --show-toplevel >/dev/null 2>&1; then
247
+ top="$(cd "$(git rev-parse --show-toplevel)" && pwd -P)"
248
+ if [ "$top" != "$(pwd -P)" ]; then
249
+ warn "El repo git raíz es $top, no este proyecto (ejecuta git init aquí)" git.root
250
+ else
251
+ ok "Repositorio git propio"
252
+ env_tracked=0
253
+ for secret in .env .env.local; do
254
+ if git ls-files --error-unmatch "$secret" >/dev/null 2>&1; then
255
+ crit git.env-tracked "$secret está versionado en git: sácalo del índice (git rm --cached $secret) y rota los secretos"
256
+ env_tracked=1
257
+ fi
258
+ done
259
+ [ "$env_tracked" -eq 0 ] && ok ".env y .env.local fuera de git"
260
+ git ls-files --error-unmatch "$H/progress.md" >/dev/null 2>&1 \
261
+ && warn "$H/progress.md está versionado; es estado de trabajo y debería estar en .gitignore" git.progress
262
+ fi
263
+ else
264
+ warn "No es un repositorio git" git.none
265
+ fi
266
+
267
+ # ─────────────────────────────────────────────────────────────────────────────
268
+ PKG_NAME=''
269
+ [ -f package.json ] && PKG_NAME="$(node -p "require('./package.json').name || ''" 2>/dev/null)"
270
+ HARNESS_PKG='@bi-analytics/arness-back'
271
+
272
+ if [ "$PKG_NAME" = "$HARNESS_PKG" ]; then
273
+ # Repo fuente del paquete: se verifica el CLI, no un proyecto NestJS.
274
+ section "Paquete $HARNESS_PKG"
275
+ info "Repo fuente del harness: se omiten las verificaciones de proyecto NestJS"
276
+ need_file package.files bin/arness.mjs
277
+ need_file package.files CHANGELOG.md
278
+ need_file package.files docs/adr/README.md
279
+ for d in $H/decisiones/[0-9][0-9][0-9][0-9]-*.md; do
280
+ [ -f "$d" ] && issue package.decisions "$d: el paquete no publica decisiones de proyecto; las del harness van en docs/adr/"
281
+ done
282
+ if [ "$QUICK" -eq 1 ]; then info "--quick: se omiten los tests del CLI"
283
+ else run_step package.tests "Tests del CLI" node --test; fi
284
+ else
285
+ # ── Instalación del harness (manifiesto e integridad) ─────────────────────
286
+ section "Instalación del harness"
287
+ if [ -f $H/.arness.json ]; then
288
+ inst_v="$(node -p "require('./$H/.arness.json').version" 2>/dev/null)"
289
+ ok "Instalado $HARNESS_PKG@$inst_v"
290
+ cli="node_modules/$HARNESS_PKG/bin/arness.mjs"
291
+ if [ -f "$cli" ]; then
292
+ dep_v="$(node -p "require('./node_modules/$HARNESS_PKG/package.json').version" 2>/dev/null)"
293
+ [ "$dep_v" != "$inst_v" ] && warn "El paquete en node_modules es $dep_v y harness/ está en $inst_v: ejecuta pnpm arness sync" install.version
294
+ if node "$cli" status --check >"$LOG_DIR/status.log" 2>&1; then ok "La base coincide con el manifiesto"
295
+ else issue install.integrity "La base del harness fue modificada o está incompleta (pnpm arness status)"; sed 's/^/ │ /' "$LOG_DIR/status.log"; fi
296
+ elif [ -f package.json ]; then
297
+ warn "$HARNESS_PKG no está en node_modules: no se pudo verificar la integridad de la base" install.integrity
298
+ fi
299
+ else
300
+ issue install.manifest "Falta $H/.arness.json: instala el harness (ver README de $HARNESS_PKG) y ejecuta arness init"
301
+ fi
302
+ fi
303
+
304
+ if [ "$PKG_NAME" = "$HARNESS_PKG" ]; then
305
+ :
306
+ elif [ ! -f package.json ]; then
307
+ section "Proyecto"
308
+ info "Sin package.json: modo solo harness. Para crear la base ejecuta la skill nest-base ($H/skills/nest-base)."
309
+ else
310
+ section "Toolchain"
311
+
312
+ want=''
313
+ [ -f .nvmrc ] && want="$(tr -d '[:space:]v' < .nvmrc)"
314
+ if command -v node >/dev/null 2>&1; then
315
+ node_v="$(node -v)"
316
+ if [ -f .nvmrc ]; then
317
+ have="${node_v#v}"
318
+ if [ "${want%%.*}" = "${have%%.*}" ]; then ok "Node $node_v (esperado v$want por .nvmrc)"
319
+ else issue toolchain.node "Node $node_v no coincide con .nvmrc (v$want)"; fi
320
+ else
321
+ issue toolchain.nvmrc "Falta .nvmrc con la versión LTS de Node"
322
+ fi
323
+ else
324
+ issue toolchain.node "Node no está instalado"
325
+ fi
326
+
327
+ # Última LTS publicada en nodejs.org. Sin red se omite: nunca bloquea por conectividad.
328
+ case "${want%%.*}" in ''|*[!0-9]*) ;; *)
329
+ if [ "${NODE_LTS_VERSION:-}" = off ]; then
330
+ info "Comparación con la última LTS de Node desactivada (NODE_LTS_VERSION=off)"
331
+ else
332
+ lts="${NODE_LTS_VERSION:-$(curl -s -m 5 https://nodejs.org/dist/index.json 2>/dev/null | node -e '
333
+ let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
334
+ try { const r = JSON.parse(s).find((x) => x.lts); if (r) console.log(r.version); } catch {}
335
+ })' 2>/dev/null)}"
336
+ lts="${lts#v}"
337
+ case "${lts%%.*}" in
338
+ ''|*[!0-9]*) info "No se pudo consultar la última LTS de Node (sin red o respuesta inválida): se omite" ;;
339
+ *)
340
+ if [ "${want%%.*}" -lt "${lts%%.*}" ]; then
341
+ issue toolchain.node-lts "Hay una LTS de Node más nueva: v$lts (.nvmrc fija v$want). No la actualices sin aprobación: analízala con $H/playbooks/update-node-lts.md y preséntale el análisis al humano"
342
+ elif [ "${want%%.*}" -eq "${lts%%.*}" ] && [ "$want" != "$lts" ]; then
343
+ info "Node v$lts es la última versión de la LTS v${lts%%.*}; .nvmrc fija v$want (actualizarla dentro de la misma LTS es opcional)"
344
+ else
345
+ ok ".nvmrc está en la última LTS de Node (v$lts)"
346
+ fi ;;
347
+ esac
348
+ fi ;;
349
+ esac
350
+
351
+ if command -v pnpm >/dev/null 2>&1; then ok "pnpm $(pnpm -v)"
352
+ else issue toolchain.pnpm "pnpm no está instalado"; fi
353
+
354
+ pm="$(node -p "require('./package.json').packageManager || ''" 2>/dev/null)"
355
+ case "$pm" in pnpm@*) ok "packageManager: $pm" ;; *) issue toolchain.package-manager "package.json debe declarar packageManager pnpm@<versión>" ;; esac
356
+
357
+ [ -f pnpm-lock.yaml ] && ok "pnpm-lock.yaml" || issue toolchain.lockfile "Falta pnpm-lock.yaml"
358
+ for lock in package-lock.json yarn.lock; do
359
+ [ -f "$lock" ] && issue toolchain.lockfile "$lock no está permitido: solo pnpm"
360
+ done
361
+
362
+ for s in lint:check format:check build test test:e2e; do
363
+ node -e "process.exit(require('./package.json').scripts?.['$s'] ? 0 : 1)" 2>/dev/null \
364
+ || issue toolchain.scripts "package.json no define el script '$s'"
365
+ done
366
+
367
+ # ───────────────────────────────────────────────────────────────────────────
368
+ section "Estructura"
369
+
370
+ for f in src/main.ts src/app.module.ts eslint.config.mjs .prettierrc .env.example \
371
+ FRONT.md Dockerfile azure-pipelines.yml postman/arness-back.postman_collection.json; do
372
+ need_file structure.files "$f"
373
+ done
374
+ # Documentación del proyecto (AGENTS.md §12): FRONT.md en la raíz y docs/ con avance y pendientes.
375
+ for f in docs/TECNICO.md docs/EVOLUTIVO.md docs/PENDIENTES.md; do
376
+ need_file docs.files "$f"
377
+ done
378
+ for d in src/config src/common/auth src/common/database src/common/cache src/common/http \
379
+ src/common/logging src/common/health src/modules test; do
380
+ need_dir structure.dirs "$d"
381
+ done
382
+
383
+ if [ -d src/modules ]; then
384
+ for mod in $(find src/modules -mindepth 2 -maxdepth 2 -type d | sort); do
385
+ m="$(basename "$mod")"
386
+ [ -f "$mod/README.md" ] || issue structure.modules "$mod: falta README.md"
387
+ [ -f "$mod/$m.module.ts" ] || issue structure.modules "$mod: falta $m.module.ts"
388
+ done
389
+ fi
390
+
391
+ # El harness no debe entrar al build ni a la imagen.
392
+ for tsc in tsconfig.json tsconfig.build.json; do
393
+ if [ -f "$tsc" ]; then
394
+ node -e "const c=JSON.parse(require('fs').readFileSync('$tsc','utf8'));process.exit((c.exclude||[]).some(e=>/^\\.?\\/?harness/.test(e))?0:1)" 2>/dev/null \
395
+ || issue structure.harness-isolation "$tsc no excluye harness/"
396
+ fi
397
+ done
398
+ if [ -f .dockerignore ]; then grep -qxE '/?harness/?' .dockerignore || issue structure.harness-isolation ".dockerignore no excluye harness/"
399
+ else issue structure.harness-isolation "Falta .dockerignore"; fi
400
+
401
+ if [ -f .gitignore ]; then
402
+ for p in .env .env.local "$H/progress.md"; do
403
+ grep -qxF "$p" .gitignore || issue structure.gitignore ".gitignore no excluye $p"
404
+ done
405
+ else
406
+ issue structure.gitignore "Falta .gitignore"
407
+ fi
408
+
409
+ if [ -f .env.example ]; then
410
+ for k in PORT DATABASES ACCESS_TOKEN JWT_ACCESS_SECRET JWT_ACCESS_EXPIRES_IN \
411
+ JWT_REFRESH_SECRET JWT_REFRESH_EXPIRES_IN CACHE_ENABLED REDIS_URL; do
412
+ grep -q "^$k=" .env.example || issue structure.env-example ".env.example no define $k"
413
+ done
414
+ grep -q '^CORS_ORIGINS=' .env.example || issue security.cors-env ".env.example no define CORS_ORIGINS (allowlist de orígenes, AGENTS.md §9)"
415
+ if [ -d src/common/monitor ]; then
416
+ for k in MONITOR_ENABLED MONITOR_SESSION_EXPIRES_IN; do
417
+ grep -q "^$k=" .env.example || issue security.monitor-env ".env.example no define $k (src/common/monitor existe)"
418
+ done
419
+ fi
420
+ dbs="$(grep -m1 '^DATABASES=' .env.example | cut -d= -f2 | tr ',' ' ')"
421
+ for db in $dbs; do
422
+ for part in HOST PORT NAME USER PASSWORD; do
423
+ grep -q "^DB_${db}_${part}=" .env.example || issue structure.env-example ".env.example no define DB_${db}_${part}"
424
+ done
425
+ done
426
+ fi
427
+
428
+ # ───────────────────────────────────────────────────────────────────────────
429
+ section "Reglas de SQL"
430
+
431
+ if [ -d src ]; then
432
+ # SQL al inicio de un literal o de una línea (consultas multilínea), también tras "(" y con escrituras.
433
+ # Se evitan falsos positivos de identificadores: SELECT = ..., WITH = ..., 'DELETE' (método HTTP).
434
+ sql_kw='SELECT([[:space:]]+[^=[:space:]]|[[:space:]]*$)|WITH[[:space:]]+(RECURSIVE[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*(\(|AS([[:space:]]|$))'
435
+ sql_kw="$sql_kw|INSERT[[:space:]]+INTO[[:space:]]|UPDATE[[:space:]]+[^[:space:]]+[[:space:]]+SET[[:space:]]|DELETE[[:space:]]+FROM[[:space:]]|MERGE[[:space:]]+INTO[[:space:]]"
436
+ sql_out="$(grep -rlE --include='*.ts' "(^|[\`'\"(])[[:space:]]*\(?[[:space:]]*($sql_kw)" src 2>/dev/null \
437
+ | grep -vE '\.sql(\.spec)?\.ts$')"
438
+ if [ -n "$sql_out" ]; then issue sql.outside "Texto SQL fuera de *.sql.ts: $(echo $sql_out)"
439
+ else ok "SQL solo en *.sql.ts"; fi
440
+
441
+ sql_write="$(grep -rliE --include='*.sql.ts' '(INSERT[[:space:]]+INTO|DELETE[[:space:]]+FROM|UPDATE[[:space:]]+[^[:space:]]+[[:space:]]+SET|TRUNCATE|(DROP|ALTER|CREATE)[[:space:]]+(TABLE|VIEW|INDEX|SCHEMA|DATABASE|FUNCTION|MATERIALIZED))' src 2>/dev/null)"
442
+ if [ -n "$sql_write" ]; then issue sql.write "Escritura o DDL en SQL (la BD es de solo lectura): $(echo $sql_write)"
443
+ else ok "Sin escrituras ni DDL"; fi
444
+ fi
445
+
446
+ # ───────────────────────────────────────────────────────────────────────────
447
+ section "Arquitectura"
448
+
449
+ if [ -f eslint.config.mjs ]; then
450
+ if [ -f eslint.boundaries.mjs ] && grep -q 'architectureBoundaries' eslint.config.mjs; then
451
+ ok "ESLint verifica las fronteras de arquitectura (eslint.boundaries.mjs)"
452
+ else
453
+ issue architecture.eslint-boundaries "eslint.config.mjs no aplica las fronteras: copia harness/skills/nest-base/templates/eslint.boundaries.mjs e impórtala (skill nest-base §4)"
454
+ fi
455
+ fi
456
+
457
+ if [ -d src ]; then
458
+ # Fronteras (AGENTS.md §4), por si el ESLint del proyecto aún no las tiene, y patrón por endpoint (§5).
459
+ arch="$(node -e '
460
+ const fs = require("fs"), path = require("path");
461
+ const walk = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
462
+ const p = path.join(dir, e.name);
463
+ return e.isDirectory() ? [p + "/", ...walk(p)] : [p];
464
+ }) : [];
465
+ const out = (id, msg) => console.log(id + "\t" + msg);
466
+ const importsOf = (f) => [...fs.readFileSync(f, "utf8").matchAll(/(?:from|import|require\()\s*["\x27]([^"\x27]+)["\x27]/g)].map((m) => m[1]);
467
+
468
+ const common = walk("src/common").filter((f) => f.endsWith(".ts"));
469
+ const bad = common.filter((f) => importsOf(f).some((s) => /(^|\/)modules(\/|$)/.test(s)));
470
+ if (bad.length) out("architecture.common-imports-modules", "src/common importa de src/modules: " + bad.join(" "));
471
+
472
+ const TYPES = [
473
+ ["controller", /\.controller\.ts$/, "controllers"], ["service", /\.service\.ts$/, "services"],
474
+ ["repository", /\.repository\.ts$/, "repositories"], ["sql", /\.sql\.ts$/, "sql"],
475
+ ["columns", /\.columns\.ts$/, "columns"], ["dto", /\.dto\.ts$/, "dto"], ["entity", /\.entity\.ts$/, "entities"],
476
+ ];
477
+ const files = walk("src/modules").filter((f) => f.endsWith(".ts") && !f.endsWith(".spec.ts"));
478
+ const crossed = [];
479
+ for (const f of files) {
480
+ const [, client, mod] = f.split("/").slice(1);
481
+ for (const s of importsOf(f)) {
482
+ const target = s.startsWith(".") ? path.normalize(path.join(path.dirname(f), s)) : s;
483
+ const m = /^src\/modules\/([^/]+)(?:\/([^/]+))?/.exec(target);
484
+ if (m && (m[1] !== client || (m[2] && m[2] !== mod))) crossed.push(f + " → " + s);
485
+ }
486
+ }
487
+ if (crossed.length) out("architecture.module-imports", "Imports entre módulos o clientes: " + crossed.join(", "));
488
+
489
+ const byDir = new Map();
490
+ for (const f of files) { const d = path.dirname(f); byDir.set(d, [...(byDir.get(d) || []), path.basename(f)]); }
491
+ for (const [dir, names] of byDir) {
492
+ if (dir.split("/").length < 4) continue;
493
+ for (const [type, re, folder] of TYPES) {
494
+ const n = names.filter((x) => re.test(x)).length;
495
+ if (n > 2 && path.basename(dir) !== folder)
496
+ out("architecture.grouping", dir + "/: " + n + " archivos " + type + "; agrúpalos en " + folder + "/ (AGENTS.md §5)");
497
+ }
498
+ }
499
+
500
+ const SPEC = ["controller", "service", "repository", "sql", "query.dto"];
501
+ for (const ctrl of files.filter((f) => f.endsWith(".controller.ts"))) {
502
+ const parts = ctrl.split("/");
503
+ if (parts.length < 5) continue;
504
+ const root = parts.slice(0, 4).join("/");
505
+ const all = walk(root).filter((f) => f.endsWith(".ts"));
506
+ const e = path.basename(ctrl, ".controller.ts");
507
+ const want = [e + ".controller.ts", e + ".service.ts", e + ".repository.ts", e + ".sql.ts", e + ".columns.ts",
508
+ e + "-query.dto.ts", e + "-filters.dto.ts", e + ".entity.ts"];
509
+ const missing = [];
510
+ for (const name of want) {
511
+ const found = all.find((f) => path.basename(f) === name);
512
+ if (!found) { missing.push(name); continue; }
513
+ if (SPEC.some((t) => name.endsWith("." + t + ".ts") || name.endsWith("-" + t + ".ts")) &&
514
+ !fs.existsSync(found.replace(/\.ts$/, ".spec.ts"))) missing.push(name.replace(/\.ts$/, ".spec.ts"));
515
+ }
516
+ if (missing.length) out("architecture.endpoint-files", "Endpoint " + e + " (" + root + "): falta " + missing.join(", "));
517
+ }
518
+ ' 2>&1)"
519
+ arch_before=$((FAILS + WARNS))
520
+ while IFS="$(printf '\t')" read -r id msg; do
521
+ [ -n "$id" ] && issue "$id" "$msg"
522
+ done <<EOF
523
+ $arch
524
+ EOF
525
+ [ "$((FAILS + WARNS))" -eq "$arch_before" ] && ok "Fronteras, agrupación y patrón por endpoint (AGENTS.md §4 y §5)"
526
+ fi
527
+
528
+ # ───────────────────────────────────────────────────────────────────────────
529
+ section "Variables de entorno"
530
+
531
+ if [ -d src ]; then
532
+ # Configuración centralizada (AGENTS.md §14): process.env solo en src/config/; los tests pueden usarlo.
533
+ if [ -f src/config/configuration.ts ]; then ok "src/config/configuration.ts"
534
+ else issue env.configuration-file "Falta src/config/configuration.ts: copia harness/skills/nest-base/templates/config/ (configuracion.md, Dónde se leen)"; fi
535
+
536
+ env_out="$(grep -rnE --include='*.ts' 'process\.env|\{[^}]*\benv\b[^}]*\}[[:space:]]*=[[:space:]]*process\b' src 2>/dev/null \
537
+ | grep -vE '^[^:]+:[0-9]+:[[:space:]]*(//|/?\*)' | cut -d: -f1 \
538
+ | grep -vE '^src/config/|\.spec\.ts$' | sort -u)"
539
+ if [ -n "$env_out" ]; then issue env.process-env "process.env fuera de src/config/ (usa ConfigService): $(echo $env_out)"
540
+ else ok "process.env solo en src/config/"; fi
541
+ fi
542
+ if [ -f eslint.config.mjs ]; then
543
+ if grep -q 'configBoundaries' eslint.config.mjs; then ok "ESLint restringe process.env a src/config/ (configBoundaries)"
544
+ else issue env.eslint "eslint.config.mjs no aplica configBoundaries de eslint.boundaries.mjs (process.env solo en src/config/)"; fi
545
+ fi
546
+
547
+ # ───────────────────────────────────────────────────────────────────────────
548
+ section "Seguridad"
549
+
550
+ if [ -f src/main.ts ]; then
551
+ if grep -qE 'enableCors[[:space:]]*\([[:space:]]*\)|cors:[[:space:]]*true|origin:[[:space:]]*(true|["'"'"']\*["'"'"'])' src/main.ts; then
552
+ issue security.cors-open "src/main.ts abre CORS a cualquier origen: usa la allowlist de CORS_ORIGINS (contrato-http.md, CORS)"
553
+ elif grep -q 'enableCors' src/main.ts; then ok "CORS configurado en src/main.ts"
554
+ else issue security.cors "src/main.ts no configura CORS con la allowlist de CORS_ORIGINS (contrato-http.md, CORS)"; fi
555
+ fi
556
+ if [ -f .env.example ] && grep -E '^CORS_ORIGINS=' .env.example | grep -q '\*'; then
557
+ issue security.cors-open ".env.example: CORS_ORIGINS no admite comodines"
558
+ fi
559
+
560
+ # Endurecimiento de auth (ADR 0017): cabeceras, límite de intentos y vida de la sesión.
561
+ has_ts() { grep -rq --include='*.ts' --exclude='*.spec.ts' "$1" "$2" 2>/dev/null; }
562
+ if [ -f src/main.ts ]; then
563
+ if grep -q 'securityHeaders' src/main.ts; then ok "Cabeceras de seguridad registradas en src/main.ts"
564
+ else issue security.headers "src/main.ts no registra securityHeaders() (contrato-http.md, Cabeceras de seguridad)"; fi
565
+ if grep -qE "trust proxy['\"][[:space:]]*,[[:space:]]*true" src/main.ts; then
566
+ issue security.trust-proxy "src/main.ts confía en cualquier proxy (trust proxy = true): usa HTTP_TRUST_PROXY_HOPS (contrato-http.md, Límite de intentos)"
567
+ fi
568
+ fi
569
+ if [ -d src/common/auth ]; then
570
+ if has_ts LoginAttemptLimiter src/common/auth; then ok "Límite de intentos en /auth/login"
571
+ else issue security.login-rate-limit "/auth/login no usa LoginAttemptLimiter (contrato-http.md, Límite de intentos)"; fi
572
+ if has_ts isSessionExpired src/common/auth; then ok "Vida máxima de la sesión en /auth/refresh"
573
+ else issue security.session-max-age "/auth/refresh no limita la vida de la sesión con auth_time (contrato-http.md, Vida máxima de la sesión)"; fi
574
+ fi
575
+ if [ -d src/common/monitor ] && ! has_ts LoginAttemptLimiter src/common/monitor; then
576
+ issue security.login-rate-limit "/monitor/login no usa LoginAttemptLimiter (contrato-http.md, Monitor)"
577
+ fi
578
+
579
+ if [ -d src/common/monitor ]; then
580
+ mon_ctrl="$(grep -rl --include='*.controller.ts' '@Controller' src/common/monitor 2>/dev/null)"
581
+ if [ -z "$mon_ctrl" ] || grep -L 'MonitorSessionGuard' $mon_ctrl | grep -q .; then
582
+ issue security.monitor-guard "El controller de src/common/monitor debe usar MonitorSessionGuard (AGENTS.md §10)"
583
+ elif grep -rqE '^[[:space:]]*@Public\(' src/common/monitor; then
584
+ issue security.monitor-guard "src/common/monitor no usa @Public(): lo protege MonitorSessionGuard (AGENTS.md §10)"
585
+ else
586
+ ok "Monitor protegido por MonitorSessionGuard"
587
+ fi
588
+ fi
589
+
590
+ # ───────────────────────────────────────────────────────────────────────────
591
+ section "Logging"
592
+
593
+ # JSON en producción; formato legible solo en desarrollo (LOG_FORMAT=pretty, contrato-http.md "Logging").
594
+ if node -e "process.exit(require('./package.json').dependencies?.['pino-pretty'] ? 0 : 1)" 2>/dev/null; then
595
+ issue logging.pretty-production "pino-pretty está en dependencies: va solo en devDependencies (producción registra JSON)"
596
+ fi
597
+ if [ -f .env.example ]; then
598
+ if grep -q '^LOG_FORMAT=' .env.example; then
599
+ if grep -qE '^NODE_ENV=production' .env.example && grep -qE '^LOG_FORMAT=pretty' .env.example; then
600
+ issue logging.pretty-production ".env.example combina NODE_ENV=production con LOG_FORMAT=pretty (la app no arranca así)"
601
+ else
602
+ ok "LOG_FORMAT definido en .env.example"
603
+ fi
604
+ else
605
+ issue logging.format-env ".env.example no define LOG_FORMAT (json|pretty; configuracion.md, App)"
606
+ fi
607
+ fi
608
+
609
+ # ───────────────────────────────────────────────────────────────────────────
610
+ if [ "$QUICK" -eq 1 ]; then
611
+ section "Calidad"
612
+ info "--quick: se omiten dependencias, lint, formato, build, tests y health"
613
+ else
614
+ section "Dependencias"
615
+ if [ -d node_modules ]; then
616
+ ok "node_modules presente"
617
+ elif [ "$INSTALL" -eq 1 ]; then
618
+ run_step quality.deps "pnpm install --frozen-lockfile" pnpm install --frozen-lockfile
619
+ else
620
+ issue quality.deps "Faltan dependencias: ejecuta ./harness/init.sh --install (pnpm install --frozen-lockfile)"
621
+ fi
622
+
623
+ if [ -d node_modules ]; then
624
+ section "Calidad"
625
+ run_step quality.lint "Lint" pnpm run -s lint:check
626
+ run_step quality.format "Formato" pnpm run -s format:check
627
+ run_step quality.build "Build" pnpm run -s build
628
+ run_step quality.test "Tests unitarios" pnpm run -s test -- --ci
629
+ [ "$E2E" -eq 1 ] && run_step quality.e2e "Tests e2e" pnpm run -s test:e2e -- --ci
630
+
631
+ # ─────────────────────────────────────────────────────────────────────
632
+ if [ "$HEALTH" -eq 1 ]; then
633
+ section "Health"
634
+ port="${PORT:-}"
635
+ if [ -z "$port" ]; then
636
+ for envf in .env.local .env; do
637
+ [ -f "$envf" ] && port="$(grep -m1 '^PORT=' "$envf" | cut -d= -f2 | tr -d '[:space:]"'"'")"
638
+ [ -n "$port" ] && break
639
+ done
640
+ fi
641
+ url="${HEALTH_URL:-http://localhost:${port:-3000}/health}"
642
+ body="$LOG_DIR/health.json"
643
+
644
+ probe() { curl -s -m 5 -o "$body" -w '%{http_code}' "$url" 2>/dev/null; }
645
+ code="$(probe)"
646
+
647
+ if [ "$code" = "000" ]; then
648
+ if [ -f dist/main.js ]; then
649
+ info "La app no está corriendo; se arranca temporalmente desde dist/main.js"
650
+ node dist/main.js >"$LOG_DIR/app.log" 2>&1 &
651
+ APP_PID=$!
652
+ waited=0; limit="${HEALTH_TIMEOUT_S:-30}"
653
+ while [ "$waited" -lt "$limit" ]; do
654
+ kill -0 "$APP_PID" 2>/dev/null || break
655
+ code="$(probe)"; [ "$code" != "000" ] && break
656
+ sleep 1; waited=$((waited + 1))
657
+ done
658
+ fi
659
+ fi
660
+
661
+ if [ "$code" = "000" ]; then
662
+ issue health.unreachable "No se pudo consultar $url (la app no responde o no arrancó)"
663
+ [ -f "$LOG_DIR/app.log" ] && tail -n 25 "$LOG_DIR/app.log" | sed 's/^/ │ /'
664
+ else
665
+ report="$(node -e '
666
+ let b; try { b = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); }
667
+ catch { console.log("INVALID"); process.exit(0); }
668
+ const d = b.details || { ...(b.info || {}), ...(b.error || {}) };
669
+ for (const [k, v] of Object.entries(d)) {
670
+ console.log([k, (v && v.status) || "unknown", (v && v.message) || ""].join("\t"));
671
+ }' "$body")"
672
+ if [ "$report" = "INVALID" ]; then
673
+ issue health.invalid "/health respondió $code con un cuerpo que no es JSON"
674
+ else
675
+ db_down=0
676
+ while IFS="$(printf '\t')" read -r key status msg; do
677
+ [ -z "$key" ] && continue
678
+ case "$key" in
679
+ db_*)
680
+ name="$(echo "${key#db_}" | tr '[:lower:]' '[:upper:]')"
681
+ if [ "$status" = "up" ]; then ok "Base de datos $name: arriba"
682
+ else issue health.db "Base de datos $name CAÍDA${msg:+: $msg}"; db_down=1; fi ;;
683
+ cache)
684
+ case "$status" in
685
+ up) ok "Caché (Redis): arriba" ;;
686
+ disabled) info "Caché deshabilitada (CACHE_ENABLED=false)" ;;
687
+ *) warn "Caché (Redis) caída${msg:+: $msg}; la API sigue funcionando en BYPASS" health.cache ;;
688
+ esac ;;
689
+ *)
690
+ [ "$status" = "up" ] && ok "$key: arriba" || warn "$key: $status" health.other ;;
691
+ esac
692
+ done <<EOF
693
+ $report
694
+ EOF
695
+ if [ "$code" != "200" ] && [ "$db_down" -eq 0 ]; then
696
+ issue health.status "/health respondió HTTP $code"
697
+ fi
698
+ fi
699
+ fi
700
+ fi
701
+ fi
702
+ fi
703
+ fi
704
+
705
+ # ─────────────────────────────────────────────────────────────────────────────
706
+ EXIT=0
707
+ { [ "$CRITS" -gt 0 ] || [ "$FAILS" -gt 0 ]; } && EXIT=1
708
+ MODE=$([ "$STRICT" -eq 1 ] && echo strict || echo default)
709
+
710
+ printf '\n%s──────────────────────────────────────────────%s\n' "$BOLD" "$RESET"
711
+ if [ -s "$ISSUES" ]; then
712
+ # Resumen agrupado por categoría (sección de la revisión).
713
+ printf '%sResumen por categoría:%s\n' "$BOLD" "$RESET"
714
+ awk -F'\t' '$1!="ignored"{ if (!($3 in seen)) { seen[$3]=1; order[++n]=$3 } items[$3]=items[$3] " - [" $1 "] " $4 ($2!="-" ? " (" $2 ")" : "") "\n" }
715
+ END { for (i=1;i<=n;i++) printf " %s\n%s", order[i], items[order[i]] }' "$ISSUES"
716
+ [ "$IGNORED" -gt 0 ] && printf ' (%d chequeo(s) silenciado(s) en %s)\n' "$IGNORED" "$CONFIG"
717
+ printf '\n'
718
+ fi
719
+ if [ "$CRITS" -gt 0 ]; then
720
+ printf '%s%s✖ Fallo crítico (%d). Corrígelo antes de continuar.%s\n' "$BOLD" "$RED" "$CRITS" "$RESET"
721
+ printf '%sAgente: detente, informa al usuario y espera instrucciones (AGENTS.md §0).%s\n' "$YELLOW" "$RESET"
722
+ elif [ "$FAILS" -gt 0 ]; then
723
+ printf '%s%s✖ %d hallazgo(s) obligatorio(s) (modo %s), %d advertencia(s).%s\n' "$BOLD" "$RED" "$FAILS" "$MODE" "$WARNS" "$RESET"
724
+ printf '%sAgente: no continúes. Informa estos fallos al usuario y espera instrucciones (AGENTS.md §0).%s\n' "$YELLOW" "$RESET"
725
+ elif [ "$WARNS" -gt 0 ]; then
726
+ printf '%s%s✔ Proyecto operativo con %d advertencia(s).%s\n' "$BOLD" "$GREEN" "$WARNS" "$RESET"
727
+ printf '%sAgente: informa las advertencias al usuario y continúa; no las corrijas si no son parte de tu tarea (AGENTS.md §0).%s\n' "$YELLOW" "$RESET"
728
+ else
729
+ printf '%s%s✔ Proyecto en buen estado%s (0 advertencias).\n' "$BOLD" "$GREEN" "$RESET"
730
+ fi
731
+
732
+ if [ "$JSON" -eq 1 ]; then
733
+ node -e '
734
+ const [file, mode, exitCode, crit, fail, warn, ign] = process.argv.slice(1);
735
+ const issues = require("fs").readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => {
736
+ const [level, id, section, message] = l.split("\t");
737
+ return { level, id: id === "-" ? null : id, section, message };
738
+ });
739
+ const summary = { critical: +crit, failures: +fail, warnings: +warn, ignored: +ign };
740
+ process.stdout.write(JSON.stringify({ version: 1, mode, exitCode: +exitCode, summary, issues }, null, 2) + "\n");
741
+ ' "$ISSUES" "$MODE" "$EXIT" "$CRITS" "$FAILS" "$WARNS" "$IGNORED" >&3
742
+ fi
743
+ exit "$EXIT"