changebook 0.4.9 → 0.5.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.
package/dist/analyze.js CHANGED
@@ -6,12 +6,12 @@
6
6
  */
7
7
  import * as path from "node:path";
8
8
  import { atlasWebUrl } from "./browser.js";
9
- import { commitDiff, execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
9
+ import { commitDiff, execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
10
10
  import { canonicalDiffHash } from "./canonical.js";
11
11
  import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
12
12
  export async function analyze(db, options = {}) {
13
13
  const cwd = path.resolve(options.dir ?? process.cwd());
14
- const projectName = path.basename(cwd);
14
+ const projectName = projectNameFor(cwd);
15
15
  let rawDiff;
16
16
  let commitHash;
17
17
  let committedAt;
package/dist/git.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * normalization live here — a drift between them would make the same commit
5
5
  * compress differently across subcommands and break the server-side dedup.
6
6
  */
7
- import { execFile } from "node:child_process";
7
+ import { execFile, execFileSync } from "node:child_process";
8
8
  import * as path from "node:path";
9
9
  import { promisify } from "node:util";
10
10
  export const execFileAsync = promisify(execFile);
@@ -114,4 +114,47 @@ export function gitErrorMessage(error) {
114
114
  }
115
115
  return error instanceof Error ? error.message : String(error);
116
116
  }
117
+ /**
118
+ * El nombre del proyecto al que reportar desde `dir`.
119
+ *
120
+ * NACE DE UN FLECO REAL. El 2026-07-25 trabajé en un worktree llamado `seo-wt` y
121
+ * el atlas creó un proyecto `seo-wt` en la cuenta de Raúl: gastó un análisis y
122
+ * dejó 4 módulos huérfanos en su selector de proyectos. No fue un bug del
123
+ * servidor: los cuatro sitios del CLI que deducen el nombre lo sacaban de
124
+ * `path.basename(cwd)`, y en un worktree eso NO es el nombre del repo.
125
+ *
126
+ * Un worktree enlazado es el MISMO repositorio: sus commits van al mismo sitio y
127
+ * su historia es la misma. Reportar a otro proyecto parte el atlas en dos por un
128
+ * detalle de cómo tienes montado el disco, y encima en silencio.
129
+ *
130
+ * Orden: `CHANGEBOOK_PROJECT` manda siempre (quien quiera un proyecto aparte por
131
+ * worktree lo dice y ya); si no, el nombre del árbol PRINCIPAL; y si no se puede
132
+ * averiguar, el basename de siempre.
133
+ *
134
+ * Se detecta comparando `--git-dir` con `--git-common-dir`: en el árbol principal
135
+ * son el mismo; en un worktree enlazado el primero es `.git/worktrees/<nombre>`.
136
+ */
137
+ export function projectNameFor(dir, env = process.env) {
138
+ const explicito = env.CHANGEBOOK_PROJECT?.trim();
139
+ if (explicito)
140
+ return explicito;
141
+ const base = path.basename(path.resolve(dir));
142
+ try {
143
+ const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
144
+ cwd: dir, encoding: "utf8", timeout: 5_000,
145
+ }).trim();
146
+ const comun = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
147
+ cwd: dir, encoding: "utf8", timeout: 5_000,
148
+ }).trim();
149
+ if (!gitDir || !comun || gitDir === comun)
150
+ return base;
151
+ // Worktree enlazado: el árbol principal es el padre del git-dir común.
152
+ const principal = path.basename(path.dirname(comun));
153
+ return principal || base;
154
+ }
155
+ catch {
156
+ // Sin git, o con una versión que no soporta estas banderas: como siempre.
157
+ return base;
158
+ }
159
+ }
117
160
  //# sourceMappingURL=git.js.map
package/dist/guard.js CHANGED
@@ -13,22 +13,71 @@
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 { createHash } from "node:crypto";
18
+ import { execFileAsync, gitPath, projectNameFor } from "./git.js";
19
19
  import { feedWarningFor } from "./feed.js";
20
+ import { avisoDeRamaPara } from "./rama.js";
20
21
  /** Exit code that asks the pre-commit hook to abort the commit. */
21
22
  export const EXIT_BLOCK = 3;
22
23
  // A commit should never feel slow because of us: whatever the network hasn't
23
24
  // answered by then is treated as "no findings".
24
25
  const GUARD_TIMEOUT_MS = 3_500;
26
+ /**
27
+ * Tope de greps para decidir a QUIEN se avisa (ver `guardFindings`, nota 3).
28
+ *
29
+ * Mas alto que el tope de refutaciones del hook (3) porque aqui el grep decide
30
+ * si el aviso SALE, no solo si se cae: quedarse corto devuelve al guardian al
31
+ * comportamiento contaminado que estamos arreglando. Y sigue siendo barato —
32
+ * ~30 ms cada uno sobre este repo, cacheados por simbolo — dentro de los 3,5 s.
33
+ */
34
+ const MAX_GREPS_DE_DESTINO = 8;
25
35
  // Open alerts move at analysis speed (one per commit at most), so a short
26
36
  // cache makes rebases/amend streaks free without risking stale warnings.
27
37
  const CACHE_TTL_MS = 5 * 60_000;
28
38
  const MAX_ALERTS = 20;
39
+ /**
40
+ * ¿Puede el REPO contestar la afirmacion de este aviso?
41
+ *
42
+ * Es la pregunta que faltaba, y la respuesta decide si el grep significa algo.
43
+ * Medido el 2026-07-26 sobre las 28 alertas con evidencia de AppAtlas: seis se
44
+ * descartaban en silencio porque su simbolo aparecia en el repo, y dos de ellas
45
+ * eran las UNICAS del registro que predijeron un incidente real —el desfase de
46
+ * esquema del 22 de julio, tres dias sin persistir alertas— porque hablaban de
47
+ * algo ausente en la BASE DE DATOS DE PRODUCCION. El simbolo estaba en el repo,
48
+ * claro: era el nombre del fichero de migracion.
49
+ *
50
+ * Una migracion presente en el repo NO prueba que su DDL haya corrido. Eso no se
51
+ * arregla con mejores greps; se arregla no haciendo el grep.
52
+ */
53
+ export function alcanceDelRepo(alert) {
54
+ const scope = (alert.evidence_scope ?? "").trim();
55
+ if (scope === "repo")
56
+ return "si";
57
+ if (scope === "prod_db" || scope === "deployed")
58
+ return "no";
59
+ // Las 99 historicas y cualquier aviso donde el modelo lo omita. No se infiere
60
+ // del texto: adivinar el ambito es exactamente lo que rompio esto.
61
+ return "sin_declarar";
62
+ }
63
+ /**
64
+ * La frase para un aviso que el repo NO puede contestar. Es informacion, no
65
+ * ruido: le dice al agente donde SI habria que mirar, que en los dos casos
66
+ * medidos era "comprueba si la migracion corrio en produccion".
67
+ */
68
+ export function dondeComprobarlo(alert) {
69
+ const scope = (alert.evidence_scope ?? "").trim();
70
+ if (scope === "prod_db") {
71
+ return "this claim is about the PRODUCTION DATABASE, not the repo — a migration file existing here does not mean its DDL ran";
72
+ }
73
+ if (scope === "deployed") {
74
+ return "this claim is about what is DEPLOYED right now, not the repo — check the deployed version, not the source";
75
+ }
76
+ return null;
77
+ }
29
78
  /**
30
79
  * 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
80
+ * the CLI sends `projectNameFor(cwd)` as projectName and the server slugifies
32
81
  * it, so reproducing that transform is how the guard finds its project row.
33
82
  */
34
83
  export function slugifyProject(value) {
@@ -82,19 +131,60 @@ function alertFiles(alert) {
82
131
  /**
83
132
  * ¿Se contradice el aviso con el codigo que hay delante?
84
133
  *
85
- * Devuelve `true` SOLO cuando la evidencia lo tumba de forma inequivoca. Sin
134
+ * Devuelve `true` SOLO cuando la evidencia lo tumba de forma INEQUIVOCA. Sin
86
135
  * evidencia, con el simbolo vacio o si la busqueda falla, devuelve `false`: la
87
136
  * duda deja pasar el aviso. Un guardian que se calla por un error de disco es
88
137
  * peor que uno ruidoso — misma leccion que el limitador que fallaba abierto y
89
138
  * nadie noto en nueve dias.
90
139
  *
140
+ * ── 2026-07-26: LA RAMA "absent" NO ERA INEQUIVOCA Y SE QUITO ───────────────
141
+ *
142
+ * Decia: expect 'absent' + el simbolo aparece => refutado ("si aparece, ya esta
143
+ * hecho"). Medido sobre las 28 alertas con evidencia de AppAtlas:
144
+ *
145
+ * expect 'present' y el simbolo ya no esta -> descartar 0 veces
146
+ * expect 'absent' y el simbolo aparece -> descartar 6 veces
147
+ *
148
+ * O sea que el 100% de los descartes venia de esa rama, y la solida no disparaba
149
+ * NUNCA. Y las seis eran de las mejores del registro:
150
+ *
151
+ * · `alert_evidence` (22 jul): "esto deja regression_warnings vacio hasta que
152
+ * se aplique la migracion en produccion". SE CUMPLIO: tres dias sin
153
+ * persistir alertas.
154
+ * · `admin_install_metrics` (25 jul): el MISMO fallo otra vez, citando el
155
+ * precedente del 22. Una de las 5 alertas que alguien juzgo y arreglo.
156
+ *
157
+ * POR QUE FALLABA: "el simbolo aparece" significa dos cosas OPUESTAS — que el
158
+ * reemplazo se revirtio (el aviso ya no aplica) o que queda una referencia
159
+ * obsoleta (el aviso CUMPLIENDOSE). Y en esas seis, peor: hablaban de algo que
160
+ * falta en la BASE DE DATOS DE PRODUCCION, mientras el grep mira el REPO. El
161
+ * simbolo esta en el repo, claro que esta: es el nombre del fichero de
162
+ * migracion. Se buscaba en un sitio una afirmacion que era sobre otro.
163
+ *
164
+ * El caso que lo paga: cuando `vite.config.ts` todavia importaba `noscriptFor`,
165
+ * la alerta que lo nombraba se "refutaba" por aqui. Ese dia el build se rompio
166
+ * por eso exactamente.
167
+ *
168
+ * QUE SE HACE AHORA con ese caso: no se descarta, se SIRVE diciendo donde
169
+ * aparece el simbolo (ver `dondeApareceElSimbolo`). Nombrar el sitio es util en
170
+ * las dos lecturas y falso en ninguna, mientras que descartar es catastrofico en
171
+ * una de las dos. Es la misma regla de siempre —la duda deja pasar el aviso—
172
+ * aplicada donde no se estaba aplicando.
173
+ *
91
174
  * `buscar` devuelve cuantas veces aparece el simbolo, o null si no se pudo
92
175
  * mirar.
93
176
  */
94
177
  export function avisoRefutado(alert, buscar) {
95
178
  const simbolo = (alert.evidence_symbol ?? "").trim();
96
179
  const espera = alert.evidence_expect;
97
- if (!simbolo || (espera !== "present" && espera !== "absent"))
180
+ // Solo 'present' puede refutarse con un grep del repo. 'absent' es ambiguo
181
+ // (ver arriba) y se resuelve sirviendo las rutas, no descartando.
182
+ if (!simbolo || espera !== "present")
183
+ return false;
184
+ // Y solo si la afirmacion es SOBRE el repo. Un 'sin_declarar' tampoco refuta:
185
+ // gatearlo no cuesta nada medido —esta rama disparo 0 veces en produccion— y
186
+ // quita el riesgo de tirar una alerta de produccion por buscar donde no era.
187
+ if (alcanceDelRepo(alert) !== "si")
98
188
  return false;
99
189
  let apariciones;
100
190
  try {
@@ -108,10 +198,77 @@ export function avisoRefutado(alert, buscar) {
108
198
  // "present": el aviso vive de que el simbolo siga ahi. Si ya no esta, el
109
199
  // conflicto que describia no puede darse — es el caso de las 3 alertas del
110
200
  // 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;
201
+ return apariciones === 0;
202
+ }
203
+ /**
204
+ * Una linea por TEXTO: si dos avisos abiertos dicen lo mismo, se sirve uno.
205
+ *
206
+ * El dedup de creacion distingue por (simbolo, expectativa) a proposito — "tiene
207
+ * que seguir" y "tiene que desaparecer" son afirmaciones opuestas y colapsarlas
208
+ * escondería una regresion real. Pero eso es la IDENTIDAD, no la PANTALLA.
209
+ *
210
+ * Visto en produccion el 2026-07-27, servido al agente:
211
+ *
212
+ * ⚠ OPEN ALERT: La funcion noscriptFor fue reemplazada por crawlerBodyFor...
213
+ * ⚠ OPEN ALERT: La funcion noscriptFor fue reemplazada por crawlerBodyFor...
214
+ * → CHECKED NOW: ...
215
+ *
216
+ * Dos alertas de verdad distintas (una 'present', otra 'absent') con el MISMO
217
+ * texto, porque la expectativa no se muestra. Para quien lo lee es una
218
+ * repeticion, y un avisador que repite se ignora igual que uno que se equivoca.
219
+ * No se puede actuar distinto sobre dos frases identicas.
220
+ *
221
+ * `informativo` decide cual sobrevive: se prefiere el que trae la linea de
222
+ * comprobacion (donde aparece el simbolo, o donde habria que mirarlo), porque es
223
+ * el unico que añade algo. Empate: el primero, y el orden de entrada se respeta.
224
+ */
225
+ export function unoPorTexto(items, texto, informativo) {
226
+ const mejor = new Map();
227
+ for (const it of items) {
228
+ const clave = texto(it).trim();
229
+ if (!clave)
230
+ continue;
231
+ const actual = mejor.get(clave);
232
+ if (!actual || (!informativo(actual) && informativo(it))) {
233
+ mejor.set(clave, it);
234
+ }
235
+ }
236
+ return [...mejor.values()];
237
+ }
238
+ /**
239
+ * Donde aparece el simbolo de un aviso 'absent', para poder CONTESTAR el
240
+ * condicional en vez de vigilarlo.
241
+ *
242
+ * Es la otra mitad del cambio de arriba. El aviso de `noscriptFor` decia
243
+ * "actualiza cualquier referencia interna a noscriptFor ... SI LOS HUBIERA", y
244
+ * la respuesta era un grep. El 56% de los textos lleva un condicional asi
245
+ * ("si los hubiera", "podria", "sugiriendo"): en vez de prohibir la redaccion
246
+ * —que es tratar el sintoma— se resuelve la condicion y se dice el hecho.
247
+ *
248
+ * Devuelve null cuando no hay nada que añadir: otro `expect`, sin simbolo, la
249
+ * busqueda fallo, o el simbolo no aparece (y entonces el aviso se sirve tal
250
+ * cual, que es lo que ya hacia).
251
+ */
252
+ export function dondeApareceElSimbolo(alert, buscarFicheros) {
253
+ const simbolo = (alert.evidence_symbol ?? "").trim();
254
+ if (!simbolo || alert.evidence_expect !== "absent")
255
+ return null;
256
+ // Con 'prod_db' o 'deployed' no se busca: decir "sigue apareciendo en X" sobre
257
+ // una afirmacion que no era del repo es afirmar algo falso con cara de hecho
258
+ // comprobado, que es peor que callarse. Para esos va `dondeComprobarlo`.
259
+ // 'sin_declarar' SI localiza: es aditivo y no puede perder un aviso.
260
+ if (alcanceDelRepo(alert) === "no")
261
+ return null;
262
+ let ficheros;
263
+ try {
264
+ ficheros = buscarFicheros(simbolo);
265
+ }
266
+ catch {
267
+ return null;
268
+ }
269
+ if (!ficheros || ficheros.length === 0)
270
+ return null;
271
+ return ficheros;
115
272
  }
116
273
  /**
117
274
  * Open alerts × staged files → warnings, deduped by (module, message).
@@ -126,20 +283,53 @@ export function avisoRefutado(alert, buscar) {
126
283
  *
127
284
  * 2. SI SIGUE EN PIE. `refutado` lo decide quien llama, que es quien tiene el
128
285
  * arbol de trabajo. De 7 avisos abiertos, 4 se caian con un grep.
286
+ *
287
+ * 3. DONDE VIVE EL SIMBOLO manda sobre la lista del aviso (2026-07-27), y esto
288
+ * nace de medir el guardian en 62 corridas reales. Aviso 2 veces; una en el
289
+ * clavo y otra falso positivo:
290
+ *
291
+ * La alerta era sobre `noscriptFor` en PUBLICACION (seoRoutes.ts,
292
+ * publish.yml) y salto al preparar `tools/bench-orientacion.mjs` — el arnes
293
+ * del benchmark, que no tiene nada que ver con noscriptFor.
294
+ *
295
+ * La causa: la lista `files` de un aviso son LOS FICHEROS DEL DIFF QUE LO
296
+ * LEVANTO, no los ficheros donde vive el simbolo. Medido sobre las 27 alertas
297
+ * con lista: **10 (37%) no nombran ni un fichero donde su simbolo este**. Y
298
+ * los peores contaminadores son los que se tocan a todas horas —
299
+ * `_shared/analysis.ts` sale en 5 listas, `bench-orientacion.mjs` en 4 —, o
300
+ * sea que cada vez que los editas arrastras avisos ajenos.
301
+ *
302
+ * Con `ficherosDelSimbolo` se pregunta donde esta el simbolo DE VERDAD. Es la
303
+ * misma pieza que sirve para contestar el condicional (`ficherosEnRepo`), aqui
304
+ * para decidir a quien se avisa.
305
+ *
306
+ * ORDEN DE PREFERENCIA, y cada escalon es un fallo-abierto del siguiente:
307
+ * donde vive el simbolo > lista del aviso > ficheros del modulo
308
+ * Si el grep falla o no encuentra nada se cae a la lista de siempre, asi que
309
+ * un fallo de disco no puede volver mudo al guardian.
129
310
  */
130
- export function guardFindings(staged, alerts, filesByModule, refutado) {
311
+ export function guardFindings(staged, alerts, filesByModule, refutado, ficherosDelSimbolo) {
131
312
  const stagedSet = new Set(staged);
132
- const seen = new Set();
313
+ // Map y no Set: al colapsar dos avisos con el mismo (modulo, texto) hay que
314
+ // QUEDARSE con los dos ids, no descartar el segundo. Se sirve una linea —dos
315
+ // frases identicas no dejan actuar distinto— pero se registran las dos
316
+ // alertas, que es lo que permitira atribuir el veredicto a la que toca.
317
+ const porClave = new Map();
133
318
  const findings = [];
134
319
  for (const alert of alerts) {
135
320
  const module = (alert.module ?? "").trim();
136
321
  const plain = (alert.plain ?? "").trim();
137
322
  if (!module || !plain)
138
323
  continue;
139
- // Las rutas del aviso mandan sobre las del modulo: dicen de QUE va, no solo
140
- // a que cajon pertenece.
324
+ // Donde vive el simbolo manda sobre la lista del aviso, y la lista del aviso
325
+ // sobre el modulo: de mas preciso a menos. Ver la nota 3 de arriba.
326
+ const porSimbolo = ficherosDelSimbolo?.(alert) ?? null;
141
327
  const propias = alertFiles(alert);
142
- const ambito = propias.length > 0 ? propias : (filesByModule.get(module) ?? []);
328
+ const ambito = porSimbolo && porSimbolo.length > 0
329
+ ? porSimbolo
330
+ : propias.length > 0
331
+ ? propias
332
+ : (filesByModule.get(module) ?? []);
143
333
  const touched = ambito.filter((f) => stagedSet.has(f));
144
334
  if (touched.length === 0)
145
335
  continue;
@@ -148,10 +338,22 @@ export function guardFindings(staged, alerts, filesByModule, refutado) {
148
338
  if (refutado?.(alert))
149
339
  continue;
150
340
  const key = module + "\u0000" + plain;
151
- if (seen.has(key))
341
+ const id = alert.id ?? null;
342
+ const ya = porClave.get(key);
343
+ if (ya) {
344
+ // El aviso ya se sirve; lo unico que queda por recoger es su identidad.
345
+ if (id && !ya.alertIds.includes(id))
346
+ ya.alertIds.push(id);
152
347
  continue;
153
- seen.add(key);
154
- findings.push({ module, plain, staged: touched });
348
+ }
349
+ const finding = {
350
+ module,
351
+ plain,
352
+ staged: touched,
353
+ alertIds: id ? [id] : [],
354
+ };
355
+ porClave.set(key, finding);
356
+ findings.push(finding);
155
357
  }
156
358
  return findings;
157
359
  }
@@ -167,13 +369,50 @@ export function guardFindings(staged, alerts, filesByModule, refutado) {
167
369
  * `.` sueltos lo convertirian en otra expresion regular.
168
370
  */
169
371
  export function contarEnRepo(dir, simbolo) {
372
+ const out = grepDelRepo(dir, simbolo, "--count");
373
+ if (out === null)
374
+ return null;
375
+ // Una linea "fichero:N" por fichero con coincidencias.
376
+ return out
377
+ .split("\n")
378
+ .filter(Boolean)
379
+ .reduce((n, l) => n + (Number(l.slice(l.lastIndexOf(":") + 1)) || 0), 0);
380
+ }
381
+ /**
382
+ * En QUE ficheros aparece el simbolo. Mismo grep, mismos pathspecs, distinta
383
+ * pregunta: `contarEnRepo` sirve para refutar y este para CONTESTAR (ver
384
+ * `dondeApareceElSimbolo`).
385
+ *
386
+ * Comparten `grepDelRepo` a proposito y no por ahorrar lineas: si divergieran
387
+ * los pathspecs, uno podria decir "no aparece" y el otro "aparece en X" sobre el
388
+ * mismo simbolo, y no habria forma de saber cual miente.
389
+ */
390
+ export function ficherosEnRepo(dir, simbolo) {
391
+ const out = grepDelRepo(dir, simbolo, "--files-with-matches");
392
+ if (out === null)
393
+ return null;
394
+ return out.split("\n").filter(Boolean).slice(0, MAX_FICHEROS_SERVIDOS);
395
+ }
396
+ /** Tope de rutas que se nombran en un aviso: la lista es una pista, no un informe. */
397
+ const MAX_FICHEROS_SERVIDOS = 4;
398
+ /**
399
+ * El grep compartido. `modo` es `--count` o `--files-with-matches`.
400
+ *
401
+ * `git grep` y no un recorrido propio: respeta .gitignore, no entra en
402
+ * node_modules y esta escrito en C. Sobre este repo tarda ~30 ms, asi que cabe
403
+ * de sobra en el presupuesto de 3,5 s del guardian.
404
+ *
405
+ * `--fixed-strings` es obligatorio: el simbolo viene de un modelo y un `$` o un
406
+ * `.` sueltos lo convertirian en otra expresion regular.
407
+ */
408
+ function grepDelRepo(dir, simbolo, modo) {
170
409
  if (!/^[A-Za-z_$][\w$.]{1,118}$/.test(simbolo))
171
410
  return null;
172
411
  try {
173
- const out = execFileSync("git", [
412
+ return execFileSync("git", [
174
413
  "grep",
175
414
  "--fixed-strings",
176
- "--count",
415
+ modo,
177
416
  "--",
178
417
  simbolo,
179
418
  // Se busca en CODIGO, nunca en prosa. Sin esto el mecanismo nace
@@ -182,25 +421,47 @@ export function contarEnRepo(dir, simbolo) {
182
421
  // aviso de tipo "esto sigue usandose" encontraria su propia cita y no
183
422
  // podria refutarse jamas. Lo cazo el test, no el diseno.
184
423
  ":!*.md",
424
+ // Y tampoco en el resto de la PROSA, por la misma razon exacta. El
425
+ // 2026-07-30, usando el producto sobre si mismo, un aviso sobre
426
+ // `reason` se sirvio diciendo «still appears in privacidad.html,
427
+ // terminos.html» — la comprobacion encontro la PALABRA en dos paginas
428
+ // legales y la presento como si hablara del codigo. Un simbolo en un
429
+ // parrafo no es una referencia: es una coincidencia de idioma.
430
+ ":!*.html",
431
+ ":!*.txt",
432
+ ":!*.csv",
433
+ ":!*.svg",
185
434
  // Con comodín a propósito: un pathspec sin comodín ("docs/") hace
186
435
  // abortar a git grep si la carpeta no existe — en cualquier repo de
187
436
  // usuario sin docs/ el buscador devolvía null y la refutación moría
188
437
  // en silencio. Lo cazó el fixture hermético del test (2026-07-21).
189
438
  ":!docs/**",
190
439
  ], { 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
440
  }
197
441
  catch (e) {
198
442
  // git grep sale con 1 cuando NO hay coincidencias: eso es un cero real, no
199
443
  // un fallo. Cualquier otro codigo si es "no he podido mirar".
200
444
  const code = e.status;
201
- return code === 1 ? 0 : null;
445
+ return code === 1 ? "" : null;
202
446
  }
203
447
  }
448
+ /**
449
+ * LA VERSIÓN DE LA FORMA DE LA CACHÉ. Súbela cuando cambie QUÉ se guarda —las
450
+ * columnas del select, un campo del que dependa una decisión—, no cuando cambie
451
+ * el código de alrededor.
452
+ *
453
+ * El caso concreto que la trae: el commit anterior añadió `evidence_scope` al
454
+ * select de las alertas. Sin versión, la caché escrita por el binario de antes
455
+ * se deserializa sin error y el guardián decide sobre ella cinco minutos más,
456
+ * con el ámbito ausente — o sea, sin refutar nada. Y no hay forma de notarlo: la
457
+ * caché vieja no está rota, solo incompleta, así que el guardián se calla en vez
458
+ * de fallar. Mismo modo de fallo que la caché de `{project_id: null}` que costó
459
+ * cinco minutos de silencio el 2026-07-26.
460
+ *
461
+ * Empieza en 1: una caché SIN `v` es anterior al versionado y se descarta
462
+ * siempre.
463
+ */
464
+ const GUARD_CACHE_V = 1;
204
465
  export async function stagedFiles(dir) {
205
466
  // -z: NUL-separated, and crucially git does NOT octal-quote non-ASCII paths
206
467
  // (default quotepath would emit "m\303\263dulo.ts", which never matches the
@@ -209,12 +470,25 @@ export async function stagedFiles(dir) {
209
470
  const { stdout } = await execFileAsync("git", ["diff", "--cached", "--name-only", "-z"], { cwd: dir, encoding: "utf8" });
210
471
  return stdout.split("\0").filter(Boolean);
211
472
  }
473
+ /**
474
+ * ¿Se puede decidir sobre esta caché? De esta forma Y fresca.
475
+ *
476
+ * Otra forma → se trata como si no hubiera caché: se vuelve a consultar. El
477
+ * guardián ya sabe hacer eso (es el camino en frío), así que descartar no añade
478
+ * ningún modo de fallo nuevo. Pura y exportada porque lo que evita —decidir
479
+ * sobre campos que no están— no se ve desde fuera: la caché vieja no está rota.
480
+ */
481
+ export function cacheDelGuardianServible(cache, ahora) {
482
+ if (!cache)
483
+ return false;
484
+ if (cache.v !== GUARD_CACHE_V)
485
+ return false;
486
+ return ahora - cache.fetched_at <= CACHE_TTL_MS;
487
+ }
212
488
  function loadCache(file) {
213
489
  try {
214
490
  const cache = JSON.parse(fs.readFileSync(file, "utf8"));
215
- if (Date.now() - cache.fetched_at > CACHE_TTL_MS)
216
- return null;
217
- return cache;
491
+ return cacheDelGuardianServible(cache, Date.now()) ? cache : null;
218
492
  }
219
493
  catch {
220
494
  return null;
@@ -233,7 +507,7 @@ async function fetchSignals(db, dir, env) {
233
507
  }
234
508
  // Same project the analyze/hook pipeline reports to: CHANGEBOOK_PROJECT
235
509
  // wins, otherwise the directory name, matched by server-side slug first.
236
- const candidate = env.CHANGEBOOK_PROJECT?.trim() || path.basename(path.resolve(dir));
510
+ const candidate = projectNameFor(dir, env);
237
511
  const slug = slugifyProject(candidate);
238
512
  let projects = slug
239
513
  ? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
@@ -245,7 +519,17 @@ async function fetchSignals(db, dir, env) {
245
519
  let filesByModule = new Map();
246
520
  const projectId = projects[0]?.id ?? null;
247
521
  if (projectId) {
248
- alerts = await db.rest(`regression_alerts?select=module,plain,created_at,evidence_symbol,evidence_expect,files&project_id=eq.${projectId}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}`);
522
+ alerts = await db.rest(
523
+ // `evidence_scope` NO es opcional aquí, por mucho que el tipo lo sea: sin
524
+ // él, `alcanceDelRepo` devuelve "sin_declarar" para TODA alerta y la
525
+ // refutación de `avisoRefutado` no descarta nada nunca. El campo se
526
+ // escribe en la base desde el 2026-07-26 y ningún cliente lo pedía; el
527
+ // comentario de `avisoRefutado` decía que esa rama "disparó 0 veces en
528
+ // producción", y disparó cero porque el dato no llegaba, no porque el gate
529
+ // fuera barato. Lo vigila `test/alcanceLlegaAlCliente.test.ts`, que lee
530
+ // ESTA línea: un test de comportamiento no lo caza, porque los stubs
531
+ // inyectan el campo a mano.
532
+ `regression_alerts?select=id,module,plain,created_at,evidence_symbol,evidence_expect,evidence_scope,evidence_line,files&project_id=eq.${projectId}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}`);
249
533
  const modules = [
250
534
  ...new Set(alerts.map((a) => (a.module ?? "").trim()).filter(Boolean)),
251
535
  ];
@@ -256,6 +540,7 @@ async function fetchSignals(db, dir, env) {
256
540
  }
257
541
  if (cacheFile) {
258
542
  const cache = {
543
+ v: GUARD_CACHE_V,
259
544
  fetched_at: Date.now(),
260
545
  project_id: projectId,
261
546
  alerts,
@@ -320,6 +605,71 @@ export function findingsMessage(findings, block) {
320
605
  : "\nReview or dismiss the alert in your atlas: changebook open\n");
321
606
  return lines.join("\n");
322
607
  }
608
+ /** Tope de rutas por fila: un registro no puede crecer sin limite por un commit gigante. */
609
+ const MAX_FICHEROS_EN_LA_ENTREGA = 50;
610
+ /**
611
+ * EL NUCLEO, sin la forma del guardian.
612
+ *
613
+ * Existe porque hay DOS canales que sirven avisos —el guardian en el commit y el
614
+ * hook antes de editar— y el libro tiene que registrar lo mismo en los dos. Si
615
+ * cada uno armara su fila, el tope de ficheros o el dedup de ids podrian
616
+ * divergir y "en total" empezaria a significar dos cosas distintas segun el
617
+ * canal. Un criterio, un sitio.
618
+ *
619
+ * `message` es lo que de verdad se sirvio, no un resumen: el hash ata la fila al
620
+ * texto exacto que se imprimio, y eso es lo que impide reescribir el consejo a
621
+ * posteriori para que parezca que acerto.
622
+ */
623
+ export function entregaDe(input) {
624
+ if (input.message.trim().length === 0)
625
+ return null;
626
+ const alertIds = [
627
+ ...new Set(input.alertIds.filter((id) => typeof id === "string" && id.length > 0)),
628
+ ];
629
+ return {
630
+ advice_hash: input.hash(input.message),
631
+ alert_ids: alertIds,
632
+ advice_files: [...new Set(input.files)].slice(0, MAX_FICHEROS_EN_LA_ENTREGA),
633
+ head_sha: input.headSha,
634
+ };
635
+ }
636
+ export function entregaDeAviso(findings, message, headSha, hash) {
637
+ if (findings.length === 0)
638
+ return null;
639
+ return entregaDe({
640
+ // flatMap y no map: un finding puede venir de varias alertas colapsadas por
641
+ // texto (ver GuardFinding.alertIds). El Set de `entregaDe` quita ademas el
642
+ // mismo aviso servido por dos rutas, que es otra cosa.
643
+ alertIds: findings.flatMap((f) => f.alertIds),
644
+ files: findings.flatMap((f) => f.staged),
645
+ message,
646
+ headSha,
647
+ hash,
648
+ });
649
+ }
650
+ /** sha256 en hex. Aparte para que `entregaDeAviso` se pueda probar sin crypto. */
651
+ export function hashDelTexto(texto) {
652
+ return createHash("sha256").update(texto, "utf8").digest("hex");
653
+ }
654
+ /**
655
+ * El commit en el que esta el repo. `null` si no se puede saber — y ese null se
656
+ * guarda tal cual: un ancla inventada es peor que ninguna. Repo recien creado
657
+ * sin commits incluido, que es el caso que devuelve error de verdad.
658
+ */
659
+ export function headShaDe(dir) {
660
+ try {
661
+ const salida = execFileSync("git", ["rev-parse", "HEAD"], {
662
+ cwd: dir,
663
+ encoding: "utf8",
664
+ timeout: 1_000,
665
+ stdio: ["ignore", "pipe", "ignore"],
666
+ }).trim();
667
+ return /^[0-9a-f]{7,40}$/.test(salida) ? salida : null;
668
+ }
669
+ catch {
670
+ return null;
671
+ }
672
+ }
323
673
  /**
324
674
  * Returns the process exit code. Everything that can go wrong resolves to 0
325
675
  * (pass): the guard informs, it does not gatekeep — except when the user
@@ -337,6 +687,13 @@ export async function runGuard(db, dir, env = process.env) {
337
687
  const pulse = await feedWarningFor(dir);
338
688
  if (pulse)
339
689
  console.error(pulse);
690
+ // ANTES del corte por credenciales a proposito: que tu rama vaya a revertir el
691
+ // trabajo de otro es un hecho de git, no del atlas. Quien no tenga sesion —o
692
+ // no tenga cuenta— tambien merece enterarse. Ver rama.ts para el porque de la
693
+ // señal (solapamiento, no "vas atrasado").
694
+ const rama = await avisoDeRamaPara(dir);
695
+ if (rama)
696
+ console.error(rama);
340
697
  if (!db.hasCredentials())
341
698
  return 0;
342
699
  let staged;
@@ -367,7 +724,35 @@ export async function runGuard(db, dir, env = process.env) {
367
724
  await logRun(dir, `timeout after ${GUARD_TIMEOUT_MS}ms — passing`);
368
725
  return 0;
369
726
  }
370
- const findings = guardFindings(staged, signals.alerts, signals.filesByModule, (alert) => avisoRefutado(alert, (simbolo) => contarEnRepo(dir, simbolo)));
727
+ // Presupuesto de greps para decidir A QUIEN se avisa. `contarEnRepo` y
728
+ // `ficherosEnRepo` son execFileSync, o sea que BLOQUEAN el bucle de eventos y
729
+ // el techo de GUARD_TIMEOUT_MS no puede desalojarlos — un Promise.race no gana
730
+ // a una llamada sincrona. Con muchas alertas abiertas esto se comeria el
731
+ // presupuesto del commit a ~30 ms por grep. Pasado el tope se cae a la lista
732
+ // del aviso, que es el comportamiento de siempre.
733
+ //
734
+ // Se cachea por simbolo porque varias alertas comparten el mismo (noscriptFor
735
+ // salio 3 veces): sin esto se pagaria el mismo grep tres veces.
736
+ let grepsRestantes = MAX_GREPS_DE_DESTINO;
737
+ const cacheSimbolo = new Map();
738
+ const ficherosDelSimbolo = (alert) => {
739
+ const simbolo = (alert.evidence_symbol ?? "").trim();
740
+ if (!simbolo)
741
+ return null;
742
+ // Misma puerta que la refutacion: en 'prod_db' o 'deployed' el repo no dice
743
+ // donde vive la afirmacion, asi que apuntar con el grep seria apuntar mal.
744
+ if (alcanceDelRepo(alert) === "no")
745
+ return null;
746
+ if (cacheSimbolo.has(simbolo))
747
+ return cacheSimbolo.get(simbolo) ?? null;
748
+ if (grepsRestantes <= 0)
749
+ return null;
750
+ grepsRestantes -= 1;
751
+ const encontrados = ficherosEnRepo(dir, simbolo);
752
+ cacheSimbolo.set(simbolo, encontrados);
753
+ return encontrados;
754
+ };
755
+ const findings = guardFindings(staged, signals.alerts, signals.filesByModule, (alert) => avisoRefutado(alert, (simbolo) => contarEnRepo(dir, simbolo)), ficherosDelSimbolo);
371
756
  const block = mode === "block";
372
757
  const message = findingsMessage(findings, block);
373
758
  // La consulta del guardián también es una consulta del atlas (QA
@@ -391,6 +776,9 @@ export async function runGuard(db, dir, env = process.env) {
391
776
  tool: "guard_precommit",
392
777
  source: "guard",
393
778
  chars_served: message.length,
779
+ // El libro de entregas. Solo viaja cuando de verdad se avisó: una
780
+ // corrida limpia sigue siendo una fila normal, purgable como siempre.
781
+ ...(entregaDeAviso(findings, message, headShaDe(dir), hashDelTexto) ?? {}),
394
782
  })
395
783
  .catch(() => { }),
396
784
  new Promise((resolve) => {