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