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 +152 -8
- package/dist/impact.js +511 -28
- package/dist/rama.js +139 -0
- package/dist/sync.js +183 -9
- package/dist/tools.js +43 -72
- package/package.json +1 -1
- package/server.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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(
|
|
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) => {
|