changebook 0.4.8 → 0.4.10

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.
package/dist/guard.js CHANGED
@@ -13,22 +13,69 @@
13
13
  * deliberately does NOT block.
14
14
  */
15
15
  import * as fs from "node:fs";
16
- import * as path from "node:path";
17
16
  import { execFileSync } from "node:child_process";
18
- import { execFileAsync, gitPath } from "./git.js";
17
+ import { execFileAsync, gitPath, projectNameFor } from "./git.js";
19
18
  import { feedWarningFor } from "./feed.js";
20
19
  /** Exit code that asks the pre-commit hook to abort the commit. */
21
20
  export const EXIT_BLOCK = 3;
22
21
  // A commit should never feel slow because of us: whatever the network hasn't
23
22
  // answered by then is treated as "no findings".
24
23
  const GUARD_TIMEOUT_MS = 3_500;
24
+ /**
25
+ * Tope de greps para decidir a QUIEN se avisa (ver `guardFindings`, nota 3).
26
+ *
27
+ * Mas alto que el tope de refutaciones del hook (3) porque aqui el grep decide
28
+ * si el aviso SALE, no solo si se cae: quedarse corto devuelve al guardian al
29
+ * comportamiento contaminado que estamos arreglando. Y sigue siendo barato —
30
+ * ~30 ms cada uno sobre este repo, cacheados por simbolo — dentro de los 3,5 s.
31
+ */
32
+ const MAX_GREPS_DE_DESTINO = 8;
25
33
  // Open alerts move at analysis speed (one per commit at most), so a short
26
34
  // cache makes rebases/amend streaks free without risking stale warnings.
27
35
  const CACHE_TTL_MS = 5 * 60_000;
28
36
  const MAX_ALERTS = 20;
37
+ /**
38
+ * ¿Puede el REPO contestar la afirmacion de este aviso?
39
+ *
40
+ * Es la pregunta que faltaba, y la respuesta decide si el grep significa algo.
41
+ * Medido el 2026-07-26 sobre las 28 alertas con evidencia de AppAtlas: seis se
42
+ * descartaban en silencio porque su simbolo aparecia en el repo, y dos de ellas
43
+ * eran las UNICAS del registro que predijeron un incidente real —el desfase de
44
+ * esquema del 22 de julio, tres dias sin persistir alertas— porque hablaban de
45
+ * algo ausente en la BASE DE DATOS DE PRODUCCION. El simbolo estaba en el repo,
46
+ * claro: era el nombre del fichero de migracion.
47
+ *
48
+ * Una migracion presente en el repo NO prueba que su DDL haya corrido. Eso no se
49
+ * arregla con mejores greps; se arregla no haciendo el grep.
50
+ */
51
+ export function alcanceDelRepo(alert) {
52
+ const scope = (alert.evidence_scope ?? "").trim();
53
+ if (scope === "repo")
54
+ return "si";
55
+ if (scope === "prod_db" || scope === "deployed")
56
+ return "no";
57
+ // Las 99 historicas y cualquier aviso donde el modelo lo omita. No se infiere
58
+ // del texto: adivinar el ambito es exactamente lo que rompio esto.
59
+ return "sin_declarar";
60
+ }
61
+ /**
62
+ * La frase para un aviso que el repo NO puede contestar. Es informacion, no
63
+ * ruido: le dice al agente donde SI habria que mirar, que en los dos casos
64
+ * medidos era "comprueba si la migracion corrio en produccion".
65
+ */
66
+ export function dondeComprobarlo(alert) {
67
+ const scope = (alert.evidence_scope ?? "").trim();
68
+ if (scope === "prod_db") {
69
+ return "this claim is about the PRODUCTION DATABASE, not the repo — a migration file existing here does not mean its DDL ran";
70
+ }
71
+ if (scope === "deployed") {
72
+ return "this claim is about what is DEPLOYED right now, not the repo — check the deployed version, not the source";
73
+ }
74
+ return null;
75
+ }
29
76
  /**
30
77
  * Same slug the backend derives from the project name (analysis.ts slugify):
31
- * the CLI sends `path.basename(cwd)` as projectName and the server slugifies
78
+ * the CLI sends `projectNameFor(cwd)` as projectName and the server slugifies
32
79
  * it, so reproducing that transform is how the guard finds its project row.
33
80
  */
34
81
  export function slugifyProject(value) {
@@ -82,19 +129,60 @@ function alertFiles(alert) {
82
129
  /**
83
130
  * ¿Se contradice el aviso con el codigo que hay delante?
84
131
  *
85
- * Devuelve `true` SOLO cuando la evidencia lo tumba de forma inequivoca. Sin
132
+ * Devuelve `true` SOLO cuando la evidencia lo tumba de forma INEQUIVOCA. Sin
86
133
  * evidencia, con el simbolo vacio o si la busqueda falla, devuelve `false`: la
87
134
  * duda deja pasar el aviso. Un guardian que se calla por un error de disco es
88
135
  * peor que uno ruidoso — misma leccion que el limitador que fallaba abierto y
89
136
  * nadie noto en nueve dias.
90
137
  *
138
+ * ── 2026-07-26: LA RAMA "absent" NO ERA INEQUIVOCA Y SE QUITO ───────────────
139
+ *
140
+ * Decia: expect 'absent' + el simbolo aparece => refutado ("si aparece, ya esta
141
+ * hecho"). Medido sobre las 28 alertas con evidencia de AppAtlas:
142
+ *
143
+ * expect 'present' y el simbolo ya no esta -> descartar 0 veces
144
+ * expect 'absent' y el simbolo aparece -> descartar 6 veces
145
+ *
146
+ * O sea que el 100% de los descartes venia de esa rama, y la solida no disparaba
147
+ * NUNCA. Y las seis eran de las mejores del registro:
148
+ *
149
+ * · `alert_evidence` (22 jul): "esto deja regression_warnings vacio hasta que
150
+ * se aplique la migracion en produccion". SE CUMPLIO: tres dias sin
151
+ * persistir alertas.
152
+ * · `admin_install_metrics` (25 jul): el MISMO fallo otra vez, citando el
153
+ * precedente del 22. Una de las 5 alertas que alguien juzgo y arreglo.
154
+ *
155
+ * POR QUE FALLABA: "el simbolo aparece" significa dos cosas OPUESTAS — que el
156
+ * reemplazo se revirtio (el aviso ya no aplica) o que queda una referencia
157
+ * obsoleta (el aviso CUMPLIENDOSE). Y en esas seis, peor: hablaban de algo que
158
+ * falta en la BASE DE DATOS DE PRODUCCION, mientras el grep mira el REPO. El
159
+ * simbolo esta en el repo, claro que esta: es el nombre del fichero de
160
+ * migracion. Se buscaba en un sitio una afirmacion que era sobre otro.
161
+ *
162
+ * El caso que lo paga: cuando `vite.config.ts` todavia importaba `noscriptFor`,
163
+ * la alerta que lo nombraba se "refutaba" por aqui. Ese dia el build se rompio
164
+ * por eso exactamente.
165
+ *
166
+ * QUE SE HACE AHORA con ese caso: no se descarta, se SIRVE diciendo donde
167
+ * aparece el simbolo (ver `dondeApareceElSimbolo`). Nombrar el sitio es util en
168
+ * las dos lecturas y falso en ninguna, mientras que descartar es catastrofico en
169
+ * una de las dos. Es la misma regla de siempre —la duda deja pasar el aviso—
170
+ * aplicada donde no se estaba aplicando.
171
+ *
91
172
  * `buscar` devuelve cuantas veces aparece el simbolo, o null si no se pudo
92
173
  * mirar.
93
174
  */
94
175
  export function avisoRefutado(alert, buscar) {
95
176
  const simbolo = (alert.evidence_symbol ?? "").trim();
96
177
  const espera = alert.evidence_expect;
97
- if (!simbolo || (espera !== "present" && espera !== "absent"))
178
+ // Solo 'present' puede refutarse con un grep del repo. 'absent' es ambiguo
179
+ // (ver arriba) y se resuelve sirviendo las rutas, no descartando.
180
+ if (!simbolo || espera !== "present")
181
+ return false;
182
+ // Y solo si la afirmacion es SOBRE el repo. Un 'sin_declarar' tampoco refuta:
183
+ // gatearlo no cuesta nada medido —esta rama disparo 0 veces en produccion— y
184
+ // quita el riesgo de tirar una alerta de produccion por buscar donde no era.
185
+ if (alcanceDelRepo(alert) !== "si")
98
186
  return false;
99
187
  let apariciones;
100
188
  try {
@@ -108,10 +196,77 @@ export function avisoRefutado(alert, buscar) {
108
196
  // "present": el aviso vive de que el simbolo siga ahi. Si ya no esta, el
109
197
  // conflicto que describia no puede darse — es el caso de las 3 alertas del
110
198
  // renombrado latestFilesByModule -> moduleFilesUnion: cero referencias.
111
- if (espera === "present")
112
- return apariciones === 0;
113
- // "absent": el aviso vive de que algo FALTE. Si aparece, ya esta hecho.
114
- return apariciones > 0;
199
+ return apariciones === 0;
200
+ }
201
+ /**
202
+ * Una linea por TEXTO: si dos avisos abiertos dicen lo mismo, se sirve uno.
203
+ *
204
+ * El dedup de creacion distingue por (simbolo, expectativa) a proposito — "tiene
205
+ * que seguir" y "tiene que desaparecer" son afirmaciones opuestas y colapsarlas
206
+ * escondería una regresion real. Pero eso es la IDENTIDAD, no la PANTALLA.
207
+ *
208
+ * Visto en produccion el 2026-07-27, servido al agente:
209
+ *
210
+ * ⚠ OPEN ALERT: La funcion noscriptFor fue reemplazada por crawlerBodyFor...
211
+ * ⚠ OPEN ALERT: La funcion noscriptFor fue reemplazada por crawlerBodyFor...
212
+ * → CHECKED NOW: ...
213
+ *
214
+ * Dos alertas de verdad distintas (una 'present', otra 'absent') con el MISMO
215
+ * texto, porque la expectativa no se muestra. Para quien lo lee es una
216
+ * repeticion, y un avisador que repite se ignora igual que uno que se equivoca.
217
+ * No se puede actuar distinto sobre dos frases identicas.
218
+ *
219
+ * `informativo` decide cual sobrevive: se prefiere el que trae la linea de
220
+ * comprobacion (donde aparece el simbolo, o donde habria que mirarlo), porque es
221
+ * el unico que añade algo. Empate: el primero, y el orden de entrada se respeta.
222
+ */
223
+ export function unoPorTexto(items, texto, informativo) {
224
+ const mejor = new Map();
225
+ for (const it of items) {
226
+ const clave = texto(it).trim();
227
+ if (!clave)
228
+ continue;
229
+ const actual = mejor.get(clave);
230
+ if (!actual || (!informativo(actual) && informativo(it))) {
231
+ mejor.set(clave, it);
232
+ }
233
+ }
234
+ return [...mejor.values()];
235
+ }
236
+ /**
237
+ * Donde aparece el simbolo de un aviso 'absent', para poder CONTESTAR el
238
+ * condicional en vez de vigilarlo.
239
+ *
240
+ * Es la otra mitad del cambio de arriba. El aviso de `noscriptFor` decia
241
+ * "actualiza cualquier referencia interna a noscriptFor ... SI LOS HUBIERA", y
242
+ * la respuesta era un grep. El 56% de los textos lleva un condicional asi
243
+ * ("si los hubiera", "podria", "sugiriendo"): en vez de prohibir la redaccion
244
+ * —que es tratar el sintoma— se resuelve la condicion y se dice el hecho.
245
+ *
246
+ * Devuelve null cuando no hay nada que añadir: otro `expect`, sin simbolo, la
247
+ * busqueda fallo, o el simbolo no aparece (y entonces el aviso se sirve tal
248
+ * cual, que es lo que ya hacia).
249
+ */
250
+ export function dondeApareceElSimbolo(alert, buscarFicheros) {
251
+ const simbolo = (alert.evidence_symbol ?? "").trim();
252
+ if (!simbolo || alert.evidence_expect !== "absent")
253
+ return null;
254
+ // Con 'prod_db' o 'deployed' no se busca: decir "sigue apareciendo en X" sobre
255
+ // una afirmacion que no era del repo es afirmar algo falso con cara de hecho
256
+ // comprobado, que es peor que callarse. Para esos va `dondeComprobarlo`.
257
+ // 'sin_declarar' SI localiza: es aditivo y no puede perder un aviso.
258
+ if (alcanceDelRepo(alert) === "no")
259
+ return null;
260
+ let ficheros;
261
+ try {
262
+ ficheros = buscarFicheros(simbolo);
263
+ }
264
+ catch {
265
+ return null;
266
+ }
267
+ if (!ficheros || ficheros.length === 0)
268
+ return null;
269
+ return ficheros;
115
270
  }
116
271
  /**
117
272
  * Open alerts × staged files → warnings, deduped by (module, message).
@@ -126,8 +281,32 @@ export function avisoRefutado(alert, buscar) {
126
281
  *
127
282
  * 2. SI SIGUE EN PIE. `refutado` lo decide quien llama, que es quien tiene el
128
283
  * arbol de trabajo. De 7 avisos abiertos, 4 se caian con un grep.
284
+ *
285
+ * 3. DONDE VIVE EL SIMBOLO manda sobre la lista del aviso (2026-07-27), y esto
286
+ * nace de medir el guardian en 62 corridas reales. Aviso 2 veces; una en el
287
+ * clavo y otra falso positivo:
288
+ *
289
+ * La alerta era sobre `noscriptFor` en PUBLICACION (seoRoutes.ts,
290
+ * publish.yml) y salto al preparar `tools/bench-orientacion.mjs` — el arnes
291
+ * del benchmark, que no tiene nada que ver con noscriptFor.
292
+ *
293
+ * La causa: la lista `files` de un aviso son LOS FICHEROS DEL DIFF QUE LO
294
+ * LEVANTO, no los ficheros donde vive el simbolo. Medido sobre las 27 alertas
295
+ * con lista: **10 (37%) no nombran ni un fichero donde su simbolo este**. Y
296
+ * los peores contaminadores son los que se tocan a todas horas —
297
+ * `_shared/analysis.ts` sale en 5 listas, `bench-orientacion.mjs` en 4 —, o
298
+ * sea que cada vez que los editas arrastras avisos ajenos.
299
+ *
300
+ * Con `ficherosDelSimbolo` se pregunta donde esta el simbolo DE VERDAD. Es la
301
+ * misma pieza que sirve para contestar el condicional (`ficherosEnRepo`), aqui
302
+ * para decidir a quien se avisa.
303
+ *
304
+ * ORDEN DE PREFERENCIA, y cada escalon es un fallo-abierto del siguiente:
305
+ * donde vive el simbolo > lista del aviso > ficheros del modulo
306
+ * Si el grep falla o no encuentra nada se cae a la lista de siempre, asi que
307
+ * un fallo de disco no puede volver mudo al guardian.
129
308
  */
130
- export function guardFindings(staged, alerts, filesByModule, refutado) {
309
+ export function guardFindings(staged, alerts, filesByModule, refutado, ficherosDelSimbolo) {
131
310
  const stagedSet = new Set(staged);
132
311
  const seen = new Set();
133
312
  const findings = [];
@@ -136,10 +315,15 @@ export function guardFindings(staged, alerts, filesByModule, refutado) {
136
315
  const plain = (alert.plain ?? "").trim();
137
316
  if (!module || !plain)
138
317
  continue;
139
- // Las rutas del aviso mandan sobre las del modulo: dicen de QUE va, no solo
140
- // a que cajon pertenece.
318
+ // Donde vive el simbolo manda sobre la lista del aviso, y la lista del aviso
319
+ // sobre el modulo: de mas preciso a menos. Ver la nota 3 de arriba.
320
+ const porSimbolo = ficherosDelSimbolo?.(alert) ?? null;
141
321
  const propias = alertFiles(alert);
142
- const ambito = propias.length > 0 ? propias : (filesByModule.get(module) ?? []);
322
+ const ambito = porSimbolo && porSimbolo.length > 0
323
+ ? porSimbolo
324
+ : propias.length > 0
325
+ ? propias
326
+ : (filesByModule.get(module) ?? []);
143
327
  const touched = ambito.filter((f) => stagedSet.has(f));
144
328
  if (touched.length === 0)
145
329
  continue;
@@ -167,13 +351,50 @@ export function guardFindings(staged, alerts, filesByModule, refutado) {
167
351
  * `.` sueltos lo convertirian en otra expresion regular.
168
352
  */
169
353
  export function contarEnRepo(dir, simbolo) {
354
+ const out = grepDelRepo(dir, simbolo, "--count");
355
+ if (out === null)
356
+ return null;
357
+ // Una linea "fichero:N" por fichero con coincidencias.
358
+ return out
359
+ .split("\n")
360
+ .filter(Boolean)
361
+ .reduce((n, l) => n + (Number(l.slice(l.lastIndexOf(":") + 1)) || 0), 0);
362
+ }
363
+ /**
364
+ * En QUE ficheros aparece el simbolo. Mismo grep, mismos pathspecs, distinta
365
+ * pregunta: `contarEnRepo` sirve para refutar y este para CONTESTAR (ver
366
+ * `dondeApareceElSimbolo`).
367
+ *
368
+ * Comparten `grepDelRepo` a proposito y no por ahorrar lineas: si divergieran
369
+ * los pathspecs, uno podria decir "no aparece" y el otro "aparece en X" sobre el
370
+ * mismo simbolo, y no habria forma de saber cual miente.
371
+ */
372
+ export function ficherosEnRepo(dir, simbolo) {
373
+ const out = grepDelRepo(dir, simbolo, "--files-with-matches");
374
+ if (out === null)
375
+ return null;
376
+ return out.split("\n").filter(Boolean).slice(0, MAX_FICHEROS_SERVIDOS);
377
+ }
378
+ /** Tope de rutas que se nombran en un aviso: la lista es una pista, no un informe. */
379
+ const MAX_FICHEROS_SERVIDOS = 4;
380
+ /**
381
+ * El grep compartido. `modo` es `--count` o `--files-with-matches`.
382
+ *
383
+ * `git grep` y no un recorrido propio: respeta .gitignore, no entra en
384
+ * node_modules y esta escrito en C. Sobre este repo tarda ~30 ms, asi que cabe
385
+ * de sobra en el presupuesto de 3,5 s del guardian.
386
+ *
387
+ * `--fixed-strings` es obligatorio: el simbolo viene de un modelo y un `$` o un
388
+ * `.` sueltos lo convertirian en otra expresion regular.
389
+ */
390
+ function grepDelRepo(dir, simbolo, modo) {
170
391
  if (!/^[A-Za-z_$][\w$.]{1,118}$/.test(simbolo))
171
392
  return null;
172
393
  try {
173
- const out = execFileSync("git", [
394
+ return execFileSync("git", [
174
395
  "grep",
175
396
  "--fixed-strings",
176
- "--count",
397
+ modo,
177
398
  "--",
178
399
  simbolo,
179
400
  // Se busca en CODIGO, nunca en prosa. Sin esto el mecanismo nace
@@ -188,17 +409,12 @@ export function contarEnRepo(dir, simbolo) {
188
409
  // en silencio. Lo cazó el fixture hermético del test (2026-07-21).
189
410
  ":!docs/**",
190
411
  ], { cwd: dir, encoding: "utf8", timeout: 2_000, maxBuffer: 4 * 1024 * 1024 });
191
- // Una linea "fichero:N" por fichero con coincidencias.
192
- return out
193
- .split("\n")
194
- .filter(Boolean)
195
- .reduce((n, l) => n + (Number(l.slice(l.lastIndexOf(":") + 1)) || 0), 0);
196
412
  }
197
413
  catch (e) {
198
414
  // git grep sale con 1 cuando NO hay coincidencias: eso es un cero real, no
199
415
  // un fallo. Cualquier otro codigo si es "no he podido mirar".
200
416
  const code = e.status;
201
- return code === 1 ? 0 : null;
417
+ return code === 1 ? "" : null;
202
418
  }
203
419
  }
204
420
  export async function stagedFiles(dir) {
@@ -233,7 +449,7 @@ async function fetchSignals(db, dir, env) {
233
449
  }
234
450
  // Same project the analyze/hook pipeline reports to: CHANGEBOOK_PROJECT
235
451
  // wins, otherwise the directory name, matched by server-side slug first.
236
- const candidate = env.CHANGEBOOK_PROJECT?.trim() || path.basename(path.resolve(dir));
452
+ const candidate = projectNameFor(dir, env);
237
453
  const slug = slugifyProject(candidate);
238
454
  let projects = slug
239
455
  ? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
@@ -367,7 +583,35 @@ export async function runGuard(db, dir, env = process.env) {
367
583
  await logRun(dir, `timeout after ${GUARD_TIMEOUT_MS}ms — passing`);
368
584
  return 0;
369
585
  }
370
- const findings = guardFindings(staged, signals.alerts, signals.filesByModule, (alert) => avisoRefutado(alert, (simbolo) => contarEnRepo(dir, simbolo)));
586
+ // Presupuesto de greps para decidir A QUIEN se avisa. `contarEnRepo` y
587
+ // `ficherosEnRepo` son execFileSync, o sea que BLOQUEAN el bucle de eventos y
588
+ // el techo de GUARD_TIMEOUT_MS no puede desalojarlos — un Promise.race no gana
589
+ // a una llamada sincrona. Con muchas alertas abiertas esto se comeria el
590
+ // presupuesto del commit a ~30 ms por grep. Pasado el tope se cae a la lista
591
+ // del aviso, que es el comportamiento de siempre.
592
+ //
593
+ // Se cachea por simbolo porque varias alertas comparten el mismo (noscriptFor
594
+ // salio 3 veces): sin esto se pagaria el mismo grep tres veces.
595
+ let grepsRestantes = MAX_GREPS_DE_DESTINO;
596
+ const cacheSimbolo = new Map();
597
+ const ficherosDelSimbolo = (alert) => {
598
+ const simbolo = (alert.evidence_symbol ?? "").trim();
599
+ if (!simbolo)
600
+ return null;
601
+ // Misma puerta que la refutacion: en 'prod_db' o 'deployed' el repo no dice
602
+ // donde vive la afirmacion, asi que apuntar con el grep seria apuntar mal.
603
+ if (alcanceDelRepo(alert) === "no")
604
+ return null;
605
+ if (cacheSimbolo.has(simbolo))
606
+ return cacheSimbolo.get(simbolo) ?? null;
607
+ if (grepsRestantes <= 0)
608
+ return null;
609
+ grepsRestantes -= 1;
610
+ const encontrados = ficherosEnRepo(dir, simbolo);
611
+ cacheSimbolo.set(simbolo, encontrados);
612
+ return encontrados;
613
+ };
614
+ const findings = guardFindings(staged, signals.alerts, signals.filesByModule, (alert) => avisoRefutado(alert, (simbolo) => contarEnRepo(dir, simbolo)), ficherosDelSimbolo);
371
615
  const block = mode === "block";
372
616
  const message = findingsMessage(findings, block);
373
617
  // La consulta del guardián también es una consulta del atlas (QA