changebook 0.4.10 → 0.6.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/guard.js CHANGED
@@ -14,8 +14,10 @@
14
14
  */
15
15
  import * as fs from "node:fs";
16
16
  import { execFileSync } from "node:child_process";
17
+ import { createHash } from "node:crypto";
17
18
  import { execFileAsync, gitPath, projectNameFor } from "./git.js";
18
19
  import { feedWarningFor } from "./feed.js";
20
+ import { avisoDeRamaPara } from "./rama.js";
19
21
  /** Exit code that asks the pre-commit hook to abort the commit. */
20
22
  export const EXIT_BLOCK = 3;
21
23
  // A commit should never feel slow because of us: whatever the network hasn't
@@ -308,7 +310,11 @@ export function dondeApareceElSimbolo(alert, buscarFicheros) {
308
310
  */
309
311
  export function guardFindings(staged, alerts, filesByModule, refutado, ficherosDelSimbolo) {
310
312
  const stagedSet = new Set(staged);
311
- 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();
312
318
  const findings = [];
313
319
  for (const alert of alerts) {
314
320
  const module = (alert.module ?? "").trim();
@@ -332,10 +338,22 @@ export function guardFindings(staged, alerts, filesByModule, refutado, ficherosD
332
338
  if (refutado?.(alert))
333
339
  continue;
334
340
  const key = module + "\u0000" + plain;
335
- 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);
336
347
  continue;
337
- seen.add(key);
338
- 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);
339
357
  }
340
358
  return findings;
341
359
  }
@@ -403,6 +421,16 @@ function grepDelRepo(dir, simbolo, modo) {
403
421
  // aviso de tipo "esto sigue usandose" encontraria su propia cita y no
404
422
  // podria refutarse jamas. Lo cazo el test, no el diseno.
405
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",
406
434
  // Con comodín a propósito: un pathspec sin comodín ("docs/") hace
407
435
  // abortar a git grep si la carpeta no existe — en cualquier repo de
408
436
  // usuario sin docs/ el buscador devolvía null y la refutación moría
@@ -417,6 +445,23 @@ function grepDelRepo(dir, simbolo, modo) {
417
445
  return code === 1 ? "" : null;
418
446
  }
419
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;
420
465
  export async function stagedFiles(dir) {
421
466
  // -z: NUL-separated, and crucially git does NOT octal-quote non-ASCII paths
422
467
  // (default quotepath would emit "m\303\263dulo.ts", which never matches the
@@ -425,12 +470,25 @@ export async function stagedFiles(dir) {
425
470
  const { stdout } = await execFileAsync("git", ["diff", "--cached", "--name-only", "-z"], { cwd: dir, encoding: "utf8" });
426
471
  return stdout.split("\0").filter(Boolean);
427
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
+ }
428
488
  function loadCache(file) {
429
489
  try {
430
490
  const cache = JSON.parse(fs.readFileSync(file, "utf8"));
431
- if (Date.now() - cache.fetched_at > CACHE_TTL_MS)
432
- return null;
433
- return cache;
491
+ return cacheDelGuardianServible(cache, Date.now()) ? cache : null;
434
492
  }
435
493
  catch {
436
494
  return null;
@@ -461,7 +519,17 @@ async function fetchSignals(db, dir, env) {
461
519
  let filesByModule = new Map();
462
520
  const projectId = projects[0]?.id ?? null;
463
521
  if (projectId) {
464
- 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}`);
465
533
  const modules = [
466
534
  ...new Set(alerts.map((a) => (a.module ?? "").trim()).filter(Boolean)),
467
535
  ];
@@ -472,6 +540,7 @@ async function fetchSignals(db, dir, env) {
472
540
  }
473
541
  if (cacheFile) {
474
542
  const cache = {
543
+ v: GUARD_CACHE_V,
475
544
  fetched_at: Date.now(),
476
545
  project_id: projectId,
477
546
  alerts,
@@ -536,6 +605,71 @@ export function findingsMessage(findings, block) {
536
605
  : "\nReview or dismiss the alert in your atlas: changebook open\n");
537
606
  return lines.join("\n");
538
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
+ }
539
673
  /**
540
674
  * Returns the process exit code. Everything that can go wrong resolves to 0
541
675
  * (pass): the guard informs, it does not gatekeep — except when the user
@@ -553,6 +687,13 @@ export async function runGuard(db, dir, env = process.env) {
553
687
  const pulse = await feedWarningFor(dir);
554
688
  if (pulse)
555
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);
556
697
  if (!db.hasCredentials())
557
698
  return 0;
558
699
  let staged;
@@ -635,6 +776,9 @@ export async function runGuard(db, dir, env = process.env) {
635
776
  tool: "guard_precommit",
636
777
  source: "guard",
637
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) ?? {}),
638
782
  })
639
783
  .catch(() => { }),
640
784
  new Promise((resolve) => {