changebook 0.4.10 → 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/guard.js +152 -8
- package/dist/impact.js +511 -28
- package/dist/rama.js +139 -0
- package/dist/sync.js +62 -0
- package/dist/tools.js +43 -2
- 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) => {
|
package/dist/impact.js
CHANGED
|
@@ -42,7 +42,8 @@ import * as fs from "node:fs";
|
|
|
42
42
|
import * as path from "node:path";
|
|
43
43
|
import { presupuestoDeTiempo } from "./context.js";
|
|
44
44
|
import { execFileAsync, gitPath, projectNameFor } from "./git.js";
|
|
45
|
-
import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
|
|
45
|
+
import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, entregaDe, ficherosEnRepo, hashDelTexto, headShaDe, slugifyProject, unoPorTexto, } from "./guard.js";
|
|
46
|
+
import { coChangePairs } from "./sync.js";
|
|
46
47
|
import { computeRecidivism, dependentsOf } from "./tools.js";
|
|
47
48
|
/**
|
|
48
49
|
* Techo duro del camino crítico. Más corto que el de `context` (2 s) porque
|
|
@@ -52,6 +53,31 @@ import { computeRecidivism, dependentsOf } from "./tools.js";
|
|
|
52
53
|
const IMPACT_TIMEOUT_MS = 1_200;
|
|
53
54
|
/** Igual que el guardián: las alertas se mueven a velocidad de análisis. */
|
|
54
55
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
56
|
+
/**
|
|
57
|
+
* LA VERSIÓN DE LA FORMA DE LA CACHÉ. Súbela cuando cambie QUÉ se guarda: las
|
|
58
|
+
* columnas de un select, un campo nuevo del que dependa el render, una clave de
|
|
59
|
+
* agrupación. No cuando cambie el código alrededor.
|
|
60
|
+
*
|
|
61
|
+
* Sin esto, una caché escrita por el binario ANTERIOR se deserializa sin error y
|
|
62
|
+
* se sirve entera durante los 5 minutos del TTL, en cada máquina que actualice.
|
|
63
|
+
* No es hipotético: el commit que añadió `evidence_scope` a este mismo select
|
|
64
|
+
* dejó exactamente esa ventana abierta, y el fallo no se ve —la caché vieja no
|
|
65
|
+
* está corrupta, solo le faltan campos, así que el render calla en vez de
|
|
66
|
+
* romperse. Un silencio se lee igual que "no hay nada que decir".
|
|
67
|
+
*
|
|
68
|
+
* Empieza en 1 y no en 2: toda caché SIN `v` es, por definición, anterior al
|
|
69
|
+
* versionado, así que se descarta pase lo que pase.
|
|
70
|
+
*
|
|
71
|
+
* ── v2 (2026-07-28): `changelog_id` en las filas ────────────────────────────
|
|
72
|
+
*
|
|
73
|
+
* Y aquí es donde esto deja de ser precaución. `coChangePairs` AGRUPA POR
|
|
74
|
+
* `changelog_id`: sin esa columna, todas las filas caen bajo la clave
|
|
75
|
+
* `undefined`, se leen como un único análisis gigantesco, y salen TODOS los
|
|
76
|
+
* módulos acoplados con TODOS. No es un fallo ruidoso — es una caché que
|
|
77
|
+
* responde, con datos que parecen razonables, durante los cinco minutos del
|
|
78
|
+
* TTL, en cada máquina que actualice. Con la versión, esas cachés se tiran.
|
|
79
|
+
*/
|
|
80
|
+
export const IMPACT_CACHE_V = 2;
|
|
55
81
|
/**
|
|
56
82
|
* Ventana del grafo. Espejo de MODULE_GRAPH_WINDOW_ROWS en tools.ts: dos
|
|
57
83
|
* ventanas distintas darían dependientes distintos según se pregunte o se
|
|
@@ -64,6 +90,19 @@ const ALERT_WINDOW_ROWS = 500;
|
|
|
64
90
|
const SEEN_TTL_MS = 30 * 60_000;
|
|
65
91
|
/** Cota del archivo de estado: es una nota, no un historial. */
|
|
66
92
|
const SEEN_MAX = 200;
|
|
93
|
+
/**
|
|
94
|
+
* Versión de la forma del estado "ya avisado". Mismo mecanismo que
|
|
95
|
+
* IMPACT_CACHE_V, con UNA diferencia deliberada: aquí un `v` AUSENTE se acepta.
|
|
96
|
+
*
|
|
97
|
+
* Porque la forma de este fichero no ha cambiado, y decir lo contrario tirando
|
|
98
|
+
* el estado sería afirmar un desajuste que no existe. Un `v` presente y
|
|
99
|
+
* DISTINTO sí se descarta, que es la protección para el día que cambie.
|
|
100
|
+
*
|
|
101
|
+
* Y el reparto de riesgo no es el mismo: una ImpactCache vieja sirve datos
|
|
102
|
+
* equivocados; un SeenState viejo, como mucho, repite un aviso o se calla uno
|
|
103
|
+
* dentro de una sesión. No justifica tirar el estado de todo el mundo hoy.
|
|
104
|
+
*/
|
|
105
|
+
const SEEN_STATE_V = 1;
|
|
67
106
|
/** Cuántos módulos se describen si el archivo pertenece a varios. */
|
|
68
107
|
const MAX_MODULES = 3;
|
|
69
108
|
/**
|
|
@@ -162,6 +201,115 @@ export function repoRelative(toplevel, filePath) {
|
|
|
162
201
|
// El atlas guarda rutas de git: siempre con "/", también en Windows.
|
|
163
202
|
return rel.split(path.sep).join("/");
|
|
164
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Devuelve los avisos servidos con la identidad de TODAS las alertas que decian
|
|
206
|
+
* lo mismo, no solo la que sobrevivio al colapso por texto.
|
|
207
|
+
*
|
|
208
|
+
* Es la mitad de impacto del arreglo que el guardian ya lleva en
|
|
209
|
+
* `guardFindings`: la PANTALLA colapsa dos frases identicas —sobre dos frases
|
|
210
|
+
* iguales no se puede actuar distinto— pero el REGISTRO no puede, porque su
|
|
211
|
+
* trabajo es la atribucion. Con una sola identidad, el veredicto sobre esta
|
|
212
|
+
* entrega se le colgaria a la alerta equivocada.
|
|
213
|
+
*
|
|
214
|
+
* Se hace aparte y no dentro de `unoPorTexto` a proposito: esa funcion es
|
|
215
|
+
* generica y de presentacion, la comparten tres sitios y no sabe —ni debe— que
|
|
216
|
+
* es un id de alerta.
|
|
217
|
+
*/
|
|
218
|
+
export function conIdentidad(servidas, todas) {
|
|
219
|
+
const idsPorTexto = new Map();
|
|
220
|
+
for (const a of todas) {
|
|
221
|
+
const texto = (a.plain ?? "").trim();
|
|
222
|
+
if (!texto || !a.id)
|
|
223
|
+
continue;
|
|
224
|
+
const lista = idsPorTexto.get(texto) ?? [];
|
|
225
|
+
if (!lista.includes(a.id))
|
|
226
|
+
lista.push(a.id);
|
|
227
|
+
idsPorTexto.set(texto, lista);
|
|
228
|
+
}
|
|
229
|
+
return servidas.map((s) => ({
|
|
230
|
+
...s,
|
|
231
|
+
alertIds: idsPorTexto.get(s.plain.trim()) ?? s.alertIds,
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* EL TECHO DEL CANAL EMPUJADO.
|
|
236
|
+
*
|
|
237
|
+
* `sync.ts` tiene `SYNC_BUDGET_CHARS` desde hace tiempo, con toda una historia
|
|
238
|
+
* detrás (2.000 → 3.000 → vuelta a 2.000, y el comentario que lo explica:
|
|
239
|
+
* «subir el techo alimenta al que se lo come»). Esto no tenía equivalente: hay
|
|
240
|
+
* `MAX_MODULES` y `MAX_NOTE_CHARS`, pero nada acotaba el TOTAL. Tres módulos con
|
|
241
|
+
* nota larga, sus dependientes, sus alertas y ahora sus co-cambios pasan de dos
|
|
242
|
+
* mil caracteres sin que nada los pare — y si la edición toca varios ficheros,
|
|
243
|
+
* se multiplica.
|
|
244
|
+
*
|
|
245
|
+
* MÁS BAJO QUE EL DEL BLOQUE, y no por simetría: el bloque de `CLAUDE.md` se
|
|
246
|
+
* lee UNA vez por sesión y esto dispara en CADA edición. Un canal que cuesta
|
|
247
|
+
* 2.000 caracteres cuarenta veces por sesión no es un canal, es un impuesto.
|
|
248
|
+
*
|
|
249
|
+
* Y el techo no recorta al tuntún: cae primero la prosa (nota), luego la
|
|
250
|
+
* correlación (co-cambio), luego la estructura (dependientes). La alerta abierta
|
|
251
|
+
* tiene suelo y no cae nunca — si eso se recortara, el canal dejaría de tener
|
|
252
|
+
* motivo para existir.
|
|
253
|
+
*/
|
|
254
|
+
const IMPACT_BUDGET_CHARS = 1_500;
|
|
255
|
+
/**
|
|
256
|
+
* Recorta a `budget` quitando por prioridad descendente, no por orden de
|
|
257
|
+
* aparición.
|
|
258
|
+
*
|
|
259
|
+
* DOS PROPIEDADES QUE NO SE NEGOCIAN:
|
|
260
|
+
*
|
|
261
|
+
* Las alertas abiertas (prio 0) NO se recortan jamás, aunque solas pasen del
|
|
262
|
+
* techo. Es el suelo, y es el mismo argumento que el «suelo del mapa» de
|
|
263
|
+
* sync.ts: un presupuesto de suma cero sin suelo acaba comiéndose justo lo
|
|
264
|
+
* que justifica el canal.
|
|
265
|
+
*
|
|
266
|
+
* CONSECUENCIA, dicha aquí para que nadie la descubra depurando: el texto
|
|
267
|
+
* devuelto PUEDE pasar de `budget`. El techo acota lo discrecional, no el
|
|
268
|
+
* suelo. Si una edición toca módulos con veinte alertas abiertas, se sirven
|
|
269
|
+
* las veinte — un canal que se calla justo cuando hay veinte cosas rotas
|
|
270
|
+
* sería exactamente lo contrario de lo que promete.
|
|
271
|
+
*
|
|
272
|
+
* Una cabecera sin contenido no se queda. `- Module **X**` a secas no dice
|
|
273
|
+
* nada que el agente no tenga delante; servirla sería gastar caracteres en
|
|
274
|
+
* ruido y, peor, parecer que se dijo algo.
|
|
275
|
+
*/
|
|
276
|
+
export function recortarAlPresupuesto(lineas, budget = IMPACT_BUDGET_CHARS) {
|
|
277
|
+
const coste = (xs) => xs.reduce((n, x) => n + x.text.length + 1, 0);
|
|
278
|
+
const dentro = new Set(lineas);
|
|
279
|
+
// De la prioridad más alta (más prescindible) a la más baja, y dentro de cada
|
|
280
|
+
// una por el final: si hay que perder co-cambios, se pierden los últimos, que
|
|
281
|
+
// `coChangePairs` ya ordenó de más fuerte a más débil.
|
|
282
|
+
const prios = [...new Set(lineas.map((l) => l.prio))].sort((a, b) => b - a);
|
|
283
|
+
for (const p of prios) {
|
|
284
|
+
if (p === 0)
|
|
285
|
+
break; // El suelo.
|
|
286
|
+
for (const l of [...lineas].reverse()) {
|
|
287
|
+
if (coste([...dentro]) <= budget)
|
|
288
|
+
break;
|
|
289
|
+
if (l.prio === p)
|
|
290
|
+
dentro.delete(l);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Cabeceras huérfanas: un módulo cuyas líneas se fueron todas, y un bloque de
|
|
294
|
+
// fichero que se quedó sin módulos.
|
|
295
|
+
const quedan = lineas.filter((l) => dentro.has(l));
|
|
296
|
+
const util = [];
|
|
297
|
+
for (let i = 0; i < quedan.length; i++) {
|
|
298
|
+
const l = quedan[i];
|
|
299
|
+
if (l.ancla === "modulo" && quedan[i + 1]?.ancla !== undefined)
|
|
300
|
+
continue;
|
|
301
|
+
if (l.ancla === "modulo" && i === quedan.length - 1)
|
|
302
|
+
continue;
|
|
303
|
+
util.push(l);
|
|
304
|
+
}
|
|
305
|
+
const conContenido = util.filter((l, i) => l.ancla !== "bloque" || util[i + 1]?.ancla === "modulo");
|
|
306
|
+
// La línea en blanco entre bloques se pone AQUÍ y no al concatenar: si la
|
|
307
|
+
// pusiera quien llama, un bloque recortado entero dejaría su separador
|
|
308
|
+
// suelto — dos saltos de línea que se leen como "aquí falta algo".
|
|
309
|
+
return conContenido
|
|
310
|
+
.map((l, i) => (l.ancla === "bloque" && i > 0 ? `\n${l.text}` : l.text))
|
|
311
|
+
.join("\n");
|
|
312
|
+
}
|
|
165
313
|
/**
|
|
166
314
|
* ¿Merece este módulo un aviso?
|
|
167
315
|
*
|
|
@@ -181,20 +329,67 @@ export function valeLaPena(m) {
|
|
|
181
329
|
* para el mismo hecho serían dos hechos para él.
|
|
182
330
|
*/
|
|
183
331
|
export function impactText(file, modules) {
|
|
184
|
-
|
|
332
|
+
return lineasDeImpacto(file, modules)
|
|
333
|
+
.map((x) => x.text)
|
|
334
|
+
.join("\n");
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* EL ORDEN DE CAÍDA, y por qué es ese.
|
|
338
|
+
*
|
|
339
|
+
* 0 · ALERTA ABIERTA — algo ya se rompió aquí. Es lo único con suelo: si esto se
|
|
340
|
+
* recorta, el canal deja de tener motivo para existir.
|
|
341
|
+
* 1 · DEPENDIENTES — quién se rompe si tocas esto. No está escrito en ninguna
|
|
342
|
+
* parte del código, así que el agente no puede deducirlo.
|
|
343
|
+
* 2 · CO-CAMBIO — más débil que lo anterior: una correlación, no una arista.
|
|
344
|
+
* 3 · NOTA — contexto, no una orden. Y es lo más caro: hasta MAX_NOTE_CHARS por
|
|
345
|
+
* módulo, o sea que tres módulos con nota se comen casi mil caracteres de
|
|
346
|
+
* prosa antes que ningún hecho.
|
|
347
|
+
*/
|
|
348
|
+
export function lineasDeImpacto(file, modules) {
|
|
349
|
+
const lines = [
|
|
350
|
+
{
|
|
351
|
+
prio: 0,
|
|
352
|
+
ancla: "bloque",
|
|
353
|
+
text: `⚠ ChangeBook — impact radius of ${file}, before you edit it:`,
|
|
354
|
+
},
|
|
355
|
+
];
|
|
185
356
|
for (const m of modules.slice(0, MAX_MODULES)) {
|
|
186
357
|
// El riesgo se nombra solo cuando es alto: ver valeLaPena.
|
|
187
358
|
const alto = m.risk === "high" || m.risk === "hotspot";
|
|
188
|
-
lines.push(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
359
|
+
lines.push({
|
|
360
|
+
prio: 0,
|
|
361
|
+
ancla: "modulo",
|
|
362
|
+
// La reincidencia viaja EN la cabecera y no en su propia línea, así que
|
|
363
|
+
// no se recorta aparte. El plan la ponía en prioridad 2; se queda porque
|
|
364
|
+
// son ~25 caracteres de señal de peligro pegados a una cabecera de 60 que
|
|
365
|
+
// igualmente se sirve — tirarla ahorraría poco y quitaría lo que más pesa
|
|
366
|
+
// de la línea. Si algún día hay que recortarla, se recorta la cabecera
|
|
367
|
+
// entera con su módulo.
|
|
368
|
+
text: `- Module **${m.module}**` +
|
|
369
|
+
(alto ? ` · risk: ${m.risk}` : "") +
|
|
370
|
+
(m.priorRegressions >= 2
|
|
371
|
+
? ` · ⚠ ${m.priorRegressions} prior regressions`
|
|
372
|
+
: ""),
|
|
373
|
+
});
|
|
193
374
|
if (m.dependents.length > 0) {
|
|
194
|
-
lines.push(
|
|
375
|
+
lines.push({
|
|
376
|
+
prio: 1,
|
|
377
|
+
text: ` - ↘ DEPENDS ON THIS: ${m.dependents.join(", ")} — check these too before you finish`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
// El co-cambio es HISTORIA, no estructura: «quién llama a quién» sale de un
|
|
381
|
+
// grafo y lo tiene medio mercado; «qué se toca junto en la práctica» solo
|
|
382
|
+
// sale del historial. Va DESPUÉS de los dependientes porque es más débil —
|
|
383
|
+
// una correlación, no una arista declarada— y el orden de las líneas es lo
|
|
384
|
+
// que dice cuál de las dos merece más confianza.
|
|
385
|
+
for (const cc of m.coChanges ?? []) {
|
|
386
|
+
lines.push({
|
|
387
|
+
prio: 2,
|
|
388
|
+
text: ` - ↔ CHANGES WITH: ${cc.module} (together in ${Math.round(cc.rate * 100)}% of its changes) — check it before you finish`,
|
|
389
|
+
});
|
|
195
390
|
}
|
|
196
391
|
for (const a of m.alerts) {
|
|
197
|
-
lines.push(` - ⚠ OPEN ALERT: ${a.plain}`);
|
|
392
|
+
lines.push({ prio: 0, text: ` - ⚠ OPEN ALERT: ${a.plain}` });
|
|
198
393
|
// La condicion, resuelta. Va en su propia linea y con el simbolo delante
|
|
199
394
|
// para que se lea como un hecho comprobado y no como parte de la prosa del
|
|
200
395
|
// modelo, que es justo la que hedgea.
|
|
@@ -207,27 +402,48 @@ export function impactText(file, modules) {
|
|
|
207
402
|
// agente el hecho y las dos salidas, que es mas informacion que la que
|
|
208
403
|
// habia en cualquiera de las dos versiones anteriores.
|
|
209
404
|
if (a.donde && a.donde.length > 0) {
|
|
210
|
-
lines.push(
|
|
211
|
-
|
|
405
|
+
lines.push({
|
|
406
|
+
// Prioridad 0 como su alerta: es la COMPROBACIÓN de lo que se afirma,
|
|
407
|
+
// y servir la afirmación sin ella deja al agente con el condicional
|
|
408
|
+
// que esta línea existe para contestar.
|
|
409
|
+
prio: 0,
|
|
410
|
+
text: ` → CHECKED NOW: "${a.simbolo}" still appears in ${a.donde.join(", ")}` +
|
|
411
|
+
` — alert expects it gone: either a reference was missed, or the alert is stale`,
|
|
412
|
+
});
|
|
212
413
|
}
|
|
213
414
|
// El caso opuesto: el repo NO puede contestar esta afirmacion. Se dice, en
|
|
214
415
|
// vez de dejar que el agente (o el refutador) la compruebe donde no era —
|
|
215
416
|
// que es lo que tiraba las dos alertas del desfase de esquema.
|
|
216
417
|
if (a.fueraDelRepo) {
|
|
217
|
-
lines.push(
|
|
418
|
+
lines.push({
|
|
419
|
+
prio: 0,
|
|
420
|
+
text: ` → NOT CHECKABLE HERE: ${a.fueraDelRepo}`,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
// LA LINEA que lo provocó. Prioridad 0 como su aviso: es la evidencia, y
|
|
424
|
+
// un aviso servido sin ella obliga al agente a reconstruir de qué se
|
|
425
|
+
// habla — que es el trabajo que este canal existe para ahorrarle.
|
|
426
|
+
if (a.linea) {
|
|
427
|
+
lines.push({ prio: 0, text: ` → TRIGGERED BY: ${a.linea}` });
|
|
218
428
|
}
|
|
219
429
|
}
|
|
220
430
|
// Con fecha y con la misma etiqueta que atlas_file_context: una nota es una
|
|
221
431
|
// observación fechada, no estado vigente. Va DESPUÉS de dependientes y
|
|
222
432
|
// alertas porque es contexto, no una orden.
|
|
223
433
|
if (m.note) {
|
|
224
|
-
lines.push(
|
|
434
|
+
lines.push({
|
|
435
|
+
prio: 3,
|
|
436
|
+
text: ` - Note from last analysis${m.noteDate ? ` (${m.noteDate})` : ""}: ${m.note}`,
|
|
437
|
+
});
|
|
225
438
|
}
|
|
226
439
|
}
|
|
227
440
|
if (modules.length > MAX_MODULES) {
|
|
228
|
-
lines.push(
|
|
441
|
+
lines.push({
|
|
442
|
+
prio: 3,
|
|
443
|
+
text: `- (+${modules.length - MAX_MODULES} more module(s) affected)`,
|
|
444
|
+
});
|
|
229
445
|
}
|
|
230
|
-
return lines
|
|
446
|
+
return lines;
|
|
231
447
|
}
|
|
232
448
|
/**
|
|
233
449
|
* Rutas que ya se avisaron en ESTA sesión y siguen frescas. Sesión distinta →
|
|
@@ -237,19 +453,27 @@ export function impactText(file, modules) {
|
|
|
237
453
|
export function rutasYaAvisadas(state, sessionId, ahora) {
|
|
238
454
|
if (!state || state.session_id !== sessionId)
|
|
239
455
|
return new Set();
|
|
456
|
+
// Otra forma de fichero: no se interpreta, se olvida. Ausente cuenta como la
|
|
457
|
+
// actual (ver SEEN_STATE_V).
|
|
458
|
+
if ((state.v ?? SEEN_STATE_V) !== SEEN_STATE_V)
|
|
459
|
+
return new Set();
|
|
240
460
|
const vivas = Object.entries(state.seen ?? {}).filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS);
|
|
241
461
|
return new Set(vivas.map(([ruta]) => ruta));
|
|
242
462
|
}
|
|
243
463
|
/** El estado siguiente, podado por TTL y por tamaño (más recientes primero). */
|
|
244
464
|
export function estadoSiguiente(state, sessionId, nuevas, ahora) {
|
|
245
|
-
const base = state &&
|
|
465
|
+
const base = state &&
|
|
466
|
+
state.session_id === sessionId &&
|
|
467
|
+
(state.v ?? SEEN_STATE_V) === SEEN_STATE_V
|
|
468
|
+
? { ...(state.seen ?? {}) }
|
|
469
|
+
: {};
|
|
246
470
|
for (const ruta of nuevas)
|
|
247
471
|
base[ruta] = ahora;
|
|
248
472
|
const podado = Object.entries(base)
|
|
249
473
|
.filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS)
|
|
250
474
|
.sort((a, b) => b[1] - a[1])
|
|
251
475
|
.slice(0, SEEN_MAX);
|
|
252
|
-
return { session_id: sessionId, seen: Object.fromEntries(podado) };
|
|
476
|
+
return { session_id: sessionId, seen: Object.fromEntries(podado), v: SEEN_STATE_V };
|
|
253
477
|
}
|
|
254
478
|
/**
|
|
255
479
|
* Módulos a los que pertenece un archivo, del más reciente al más antiguo.
|
|
@@ -282,9 +506,185 @@ export function modulosDelArchivo(file, rows) {
|
|
|
282
506
|
}
|
|
283
507
|
return [...out.entries()].map(([module, v]) => ({ module, ...v }));
|
|
284
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* ¿Se puede servir esta caché? Fresca Y de esta forma.
|
|
511
|
+
*
|
|
512
|
+
* Exportada para poder probar el descarte sin montar un repo: es una función
|
|
513
|
+
* pura y el fallo que evita —servir campos que no están— no se ve desde fuera.
|
|
514
|
+
*/
|
|
515
|
+
export function cacheServible(cached, ahora) {
|
|
516
|
+
if (!cached)
|
|
517
|
+
return false;
|
|
518
|
+
if (cached.v !== IMPACT_CACHE_V)
|
|
519
|
+
return false;
|
|
520
|
+
return ahora - cached.fetched_at < CACHE_TTL_MS;
|
|
521
|
+
}
|
|
285
522
|
async function cachePath(dir) {
|
|
286
523
|
return gitPath(dir, "changebook-impact-cache.json").catch(() => null);
|
|
287
524
|
}
|
|
525
|
+
// ── El libro de entregas del canal empujado ──────────────────────────────────
|
|
526
|
+
//
|
|
527
|
+
// El hook PreToolUse es el canal MÁS frecuente del atlas —dispara en cada
|
|
528
|
+
// edición— y hasta hoy servía consejo sin dejar rastro: "¿acertó el aviso?" solo
|
|
529
|
+
// se podía responder del guardián pre-commit, que es el que menos corre.
|
|
530
|
+
//
|
|
531
|
+
// EL DISEÑO LO COMPLICA A PROPÓSITO, y hay que respetarlo: el camino crítico de
|
|
532
|
+
// `printImpact` no toca la red JAMÁS (ver `atlasSignals`) y tiene techo de
|
|
533
|
+
// 1.200 ms. Un insert síncrono rompería la regla que sostiene todo esto — el
|
|
534
|
+
// hook nunca puede costar tiempo perceptible.
|
|
535
|
+
//
|
|
536
|
+
// Por eso la entrega se ANOTA en disco (un append, sin leer ni parsear nada) y
|
|
537
|
+
// la sirve al servidor `impact --warm`, que ya corre desacoplado y ya toca la
|
|
538
|
+
// red. Escribir y enviar quedan separados por un proceso: si el editor mata al
|
|
539
|
+
// hook entre las dos cosas, la línea sigue en el fichero y se manda en la
|
|
540
|
+
// siguiente ronda.
|
|
541
|
+
/** Cota de la cola. Mismo patrón que el log del guardián: por tamaño, no por olvido. */
|
|
542
|
+
const QUEUE_MAX_BYTES = 65_536;
|
|
543
|
+
const QUEUE_KEEP_BYTES = 32_768;
|
|
544
|
+
async function queuePath(dir) {
|
|
545
|
+
return gitPath(dir, "changebook-impact-queue.jsonl").catch(() => null);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Anota una entrega. JSONL y no JSON: un append no tiene que leer lo que ya hay,
|
|
549
|
+
* y dos procesos solapados (dos ediciones seguidas) no pueden pisarse el fichero
|
|
550
|
+
* como sí harían leyendo-modificando-escribiendo un array.
|
|
551
|
+
*
|
|
552
|
+
* Best-effort de principio a fin: registrar la entrega no puede romper la
|
|
553
|
+
* edición que la provocó.
|
|
554
|
+
*/
|
|
555
|
+
export function encolarEntrega(file, entrega) {
|
|
556
|
+
if (!file)
|
|
557
|
+
return;
|
|
558
|
+
try {
|
|
559
|
+
// Recortar ANTES de anexar, igual que el log del guardián. El recorte parte
|
|
560
|
+
// por la mitad de una línea a propósito: reconstruirla costaría leer y
|
|
561
|
+
// parsear el fichero entero en el camino crítico, y la línea rota se tira
|
|
562
|
+
// sola al drenar (ver `entregasDeLaCola`). Se pierde UNA entrega de hace
|
|
563
|
+
// rato antes que gastar milisegundos de cada edición.
|
|
564
|
+
try {
|
|
565
|
+
if (fs.statSync(file).size > QUEUE_MAX_BYTES) {
|
|
566
|
+
const keep = fs.readFileSync(file).subarray(-QUEUE_KEEP_BYTES);
|
|
567
|
+
fs.writeFileSync(file, keep);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// Aún no existe: nada que recortar.
|
|
572
|
+
}
|
|
573
|
+
fs.appendFileSync(file, `${JSON.stringify(entrega)}\n`);
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
// Sin sitio donde anotar, el hook sigue avisando igual. El registro es una
|
|
577
|
+
// segunda lectura del mismo hecho, nunca el hecho.
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Las entregas legibles de la cola. Las líneas rotas se tiran en silencio: la
|
|
582
|
+
* primera puede venir cortada por el recorte, y una entrega ilegible no es un
|
|
583
|
+
* error del que haya que informar a nadie — es una que no se pudo registrar.
|
|
584
|
+
*/
|
|
585
|
+
export function entregasDeLaCola(contenido) {
|
|
586
|
+
const fuera = [];
|
|
587
|
+
for (const linea of contenido.split("\n")) {
|
|
588
|
+
if (!linea.trim())
|
|
589
|
+
continue;
|
|
590
|
+
try {
|
|
591
|
+
const e = JSON.parse(linea);
|
|
592
|
+
// El CHECK de la migración exige sha256 en hex; una fila que no lo cumpla
|
|
593
|
+
// haría fallar el insert de TODAS las demás si fueran en lote.
|
|
594
|
+
if (typeof e?.advice_hash === "string" && /^[0-9a-f]{64}$/.test(e.advice_hash)) {
|
|
595
|
+
fuera.push(e);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
// Línea partida por el recorte, o basura. Se ignora.
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return fuera;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Las que TODAVÍA no están registradas, por (advice_hash, head_sha).
|
|
606
|
+
*
|
|
607
|
+
* Es la respuesta a "y si el proceso muere entre insertar y vaciar la cola":
|
|
608
|
+
* la cola se vacía DESPUÉS de insertar, así que morir en medio deja las líneas
|
|
609
|
+
* puestas y la siguiente ronda las volvería a mandar. Preguntar primero cuáles
|
|
610
|
+
* ya están hace que reenviar sea gratis, que es la misma propiedad que ya tiene
|
|
611
|
+
* `atlas_record_change`.
|
|
612
|
+
*
|
|
613
|
+
* Pura para poder probar el caso sin red, que es el que no se ve venir.
|
|
614
|
+
*/
|
|
615
|
+
export function entregasSinRegistrar(cola, yaRegistradas) {
|
|
616
|
+
const clave = (h, s) => `${h ?? ""}\u0000${s ?? ""}`;
|
|
617
|
+
const vistas = new Set(yaRegistradas.map((r) => clave(r.advice_hash, r.head_sha)));
|
|
618
|
+
const fuera = [];
|
|
619
|
+
for (const e of cola) {
|
|
620
|
+
const k = clave(e.advice_hash, e.head_sha);
|
|
621
|
+
// Contra el servidor Y contra sí misma: la misma edición repetida dentro de
|
|
622
|
+
// la cola es una sola entrega, no dos.
|
|
623
|
+
if (vistas.has(k))
|
|
624
|
+
continue;
|
|
625
|
+
vistas.add(k);
|
|
626
|
+
fuera.push(e);
|
|
627
|
+
}
|
|
628
|
+
return fuera;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Vacía la cola contra `atlas_reads`. Corre en `impact --warm`, o sea en un
|
|
632
|
+
* proceso aparte del que edita y que ya paga la red de todas formas.
|
|
633
|
+
*
|
|
634
|
+
* NO se drena también en `changebook guard`: el warm corre en cuanto la caché
|
|
635
|
+
* caduca (5 minutos), así que durante una sesión de edición la cola se vacía
|
|
636
|
+
* sola una y otra vez. Lo que queda sin mandar al cerrar el editor sigue en el
|
|
637
|
+
* fichero y se manda en la siguiente sesión — se retrasa, no se pierde.
|
|
638
|
+
*/
|
|
639
|
+
async function drenarCola(db, dir) {
|
|
640
|
+
const file = await queuePath(dir);
|
|
641
|
+
if (!file)
|
|
642
|
+
return;
|
|
643
|
+
let contenido;
|
|
644
|
+
try {
|
|
645
|
+
contenido = fs.readFileSync(file, "utf8");
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return; // No hay cola: el caso normal.
|
|
649
|
+
}
|
|
650
|
+
const cola = entregasDeLaCola(contenido);
|
|
651
|
+
if (cola.length === 0) {
|
|
652
|
+
try {
|
|
653
|
+
fs.rmSync(file, { force: true });
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
/* da igual */
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const hashes = [...new Set(cola.map((e) => e.advice_hash))];
|
|
661
|
+
const yaRegistradas = await db
|
|
662
|
+
.rest(`atlas_reads?select=advice_hash,head_sha&advice_hash=in.(${hashes.join(",")})&limit=${hashes.length * 4}`)
|
|
663
|
+
.catch(() => []);
|
|
664
|
+
const pendientes = entregasSinRegistrar(cola, yaRegistradas);
|
|
665
|
+
for (const e of pendientes) {
|
|
666
|
+
const { project_id, chars_served, ...entrega } = e;
|
|
667
|
+
// Una a una y no en lote: un insert que falle no puede llevarse por delante
|
|
668
|
+
// las otras entregas, que ya no estarían en ningún sitio.
|
|
669
|
+
await db
|
|
670
|
+
.insertRow("atlas_reads", {
|
|
671
|
+
project_id,
|
|
672
|
+
tool: "impact_pretooluse",
|
|
673
|
+
source: "stdio",
|
|
674
|
+
chars_served,
|
|
675
|
+
...entrega,
|
|
676
|
+
})
|
|
677
|
+
.catch(() => { });
|
|
678
|
+
}
|
|
679
|
+
// Se vacía DESPUÉS. Si el proceso muere aquí, la siguiente ronda pregunta y
|
|
680
|
+
// no duplica (ver `entregasSinRegistrar`).
|
|
681
|
+
try {
|
|
682
|
+
fs.rmSync(file, { force: true });
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
// La cola se recortará sola por tamaño; el dedup impide que se repitan.
|
|
686
|
+
}
|
|
687
|
+
}
|
|
288
688
|
function leerJson(file) {
|
|
289
689
|
if (!file)
|
|
290
690
|
return null;
|
|
@@ -318,7 +718,14 @@ async function fetchAtlas(db, dir, env) {
|
|
|
318
718
|
}
|
|
319
719
|
const projectId = projects[0]?.id ?? null;
|
|
320
720
|
if (!projectId) {
|
|
321
|
-
return {
|
|
721
|
+
return {
|
|
722
|
+
v: IMPACT_CACHE_V,
|
|
723
|
+
fetched_at: Date.now(),
|
|
724
|
+
project_id: null,
|
|
725
|
+
rows: [],
|
|
726
|
+
deps: [],
|
|
727
|
+
alerts: [],
|
|
728
|
+
};
|
|
322
729
|
}
|
|
323
730
|
// TRES consultas en paralelo, una vez cada 5 minutos y fuera del camino
|
|
324
731
|
// crítico, así que el número de rondas aquí da igual. Lo que NO da igual es
|
|
@@ -336,16 +743,34 @@ async function fetchAtlas(db, dir, env) {
|
|
|
336
743
|
// reincidencia (all-time) salen del mismo conjunto.
|
|
337
744
|
const [rows, deps, alerts] = await Promise.all([
|
|
338
745
|
db
|
|
339
|
-
.rest(
|
|
746
|
+
.rest(
|
|
747
|
+
// `changelog_id` para el co-cambio (ver ImpactCache["rows"]). Va en la
|
|
748
|
+
// consulta que YA se hacía, no en una cuarta: el co-cambio no puede
|
|
749
|
+
// costar una ronda de red más en el canal que corre en cada edición.
|
|
750
|
+
`change_module?select=module,files,risk,note,created_at,changelog_id&project_id=eq.${projectId}&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
|
|
340
751
|
.catch(() => []),
|
|
341
752
|
db
|
|
342
753
|
.rest(`change_module?select=module,deps&project_id=eq.${projectId}&deps=not.is.null&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
|
|
343
754
|
.catch(() => []),
|
|
344
755
|
db
|
|
345
|
-
.rest(
|
|
756
|
+
.rest(
|
|
757
|
+
// `evidence_scope` decide si el grep de refutación significa algo (ver
|
|
758
|
+
// el comentario del mismo select en guard.ts). `id` y `created_at` no
|
|
759
|
+
// los lee nadie TODAVÍA: son la identidad de la alerta, que es lo que
|
|
760
|
+
// hace falta para poder atribuir después "se te avisó de esto" a la fila
|
|
761
|
+
// que lo dijo. Vienen en la misma consulta porque son gratis aquí y una
|
|
762
|
+
// caché escrita sin ellos sobreviviría 5 minutos a quien los espere.
|
|
763
|
+
`regression_alerts?select=id,module,plain,created_at,files,resolved_at,evidence_symbol,evidence_expect,evidence_scope,evidence_line&project_id=eq.${projectId}&order=created_at.desc&limit=${ALERT_WINDOW_ROWS}`)
|
|
346
764
|
.catch(() => []),
|
|
347
765
|
]);
|
|
348
|
-
return {
|
|
766
|
+
return {
|
|
767
|
+
v: IMPACT_CACHE_V,
|
|
768
|
+
fetched_at: Date.now(),
|
|
769
|
+
project_id: projectId,
|
|
770
|
+
rows,
|
|
771
|
+
deps,
|
|
772
|
+
alerts,
|
|
773
|
+
};
|
|
349
774
|
}
|
|
350
775
|
/**
|
|
351
776
|
* Las señales, SIEMPRE de disco. Nunca de la red.
|
|
@@ -371,7 +796,10 @@ async function atlasSignals(db, dir) {
|
|
|
371
796
|
if (!file)
|
|
372
797
|
return null;
|
|
373
798
|
const cached = leerJson(file);
|
|
374
|
-
|
|
799
|
+
// Una caché de otra versión NO se sirve y además dispara el recalentado, que
|
|
800
|
+
// es lo mismo que hace una caducada: en las dos, lo que hay en disco no
|
|
801
|
+
// responde a la pregunta que se está haciendo.
|
|
802
|
+
if (cacheServible(cached, Date.now()))
|
|
375
803
|
return cached;
|
|
376
804
|
if (db.hasCredentials())
|
|
377
805
|
calentarDesacoplado(dir);
|
|
@@ -409,10 +837,16 @@ export async function warmImpactCache(db, dir, env = process.env) {
|
|
|
409
837
|
const file = await cachePath(dir);
|
|
410
838
|
if (!file)
|
|
411
839
|
return;
|
|
840
|
+
// El libro de entregas ANTES de la puerta de frescura: si se pusiera
|
|
841
|
+
// después, una caché todavía fresca haría volver a este proceso por donde
|
|
842
|
+
// vino y la cola no se vaciaría nunca. El fallo sería invisible —el hook
|
|
843
|
+
// seguiría avisando igual— y "¿acertó el aviso?" seguiría sin respuesta por
|
|
844
|
+
// el canal más frecuente, que es justo lo que esto viene a arreglar.
|
|
845
|
+
await drenarCola(db, dir);
|
|
412
846
|
// Carrera entre dos ediciones seguidas: si otro proceso ya dejó una caché
|
|
413
847
|
// fresca mientras este arrancaba, no gastar dos veces la misma consulta.
|
|
414
848
|
const cached = leerJson(file);
|
|
415
|
-
if (cached
|
|
849
|
+
if (cacheServible(cached, Date.now()))
|
|
416
850
|
return;
|
|
417
851
|
const fresco = await fetchAtlas(db, dir, env);
|
|
418
852
|
// Un calentamiento que NO resolvió el proyecto no se cachea.
|
|
@@ -515,8 +949,23 @@ async function buildImpact(db, payload) {
|
|
|
515
949
|
return dondeApareceElSimbolo(a, (s) => ficherosEnRepo(toplevel, s));
|
|
516
950
|
};
|
|
517
951
|
const depsRows = signals.deps ?? [];
|
|
518
|
-
|
|
952
|
+
// Co-cambio: UNA vez por corrida, no por módulo. `coChangePairs` recorre la
|
|
953
|
+
// ventana entera y ya trae sus propios umbrales (MIN_PAIR_COUNT=3,
|
|
954
|
+
// MIN_PAIR_RATE=0.6, MAX_COUPLINGS=5) y su exclusión de módulos de pruebas.
|
|
955
|
+
// Se importa de sync.ts en vez de copiarla: es la MISMA pregunta que contesta
|
|
956
|
+
// el bloque de CLAUDE.md, y dos respuestas distintas al mismo hecho es lo que
|
|
957
|
+
// enseña al agente a no fiarse de ninguna.
|
|
958
|
+
const parejas = coChangePairs(signals.rows.filter((r) => typeof r.changelog_id === "string" && r.changelog_id.length > 0));
|
|
959
|
+
/** Con quién cambia junto un módulo, mirando la pareja por los dos lados. */
|
|
960
|
+
const coCambiosDe = (modulo) => parejas
|
|
961
|
+
.filter((p) => p.a === modulo || p.b === modulo)
|
|
962
|
+
.map((p) => ({ module: p.a === modulo ? p.b : p.a, rate: p.rate }));
|
|
963
|
+
// Lineas con prioridad, no texto ya cocido: el techo se aplica al TOTAL, asi
|
|
964
|
+
// que hay que poder recortar despues de saber cuanto ocupa todo junto.
|
|
965
|
+
const lineas = [];
|
|
519
966
|
const avisadas = [];
|
|
967
|
+
/** Las alertas que de verdad salieron en el texto. Ver el encolado del final. */
|
|
968
|
+
const idsServidos = [];
|
|
520
969
|
for (const file of pendientes) {
|
|
521
970
|
const modulos = modulosDelArchivo(file, signals.rows);
|
|
522
971
|
if (modulos.length === 0)
|
|
@@ -529,10 +978,11 @@ async function buildImpact(db, payload) {
|
|
|
529
978
|
note: m.note,
|
|
530
979
|
noteDate: m.noteDate,
|
|
531
980
|
dependents: dependientes.get(m.module) ?? [],
|
|
981
|
+
coChanges: coCambiosDe(m.module),
|
|
532
982
|
// Una linea por texto: dos avisos con la MISMA frase no le dejan al
|
|
533
983
|
// agente hacer nada distinto, por mucho que por dentro sean
|
|
534
984
|
// afirmaciones opuestas. Gana el que trae linea de comprobacion.
|
|
535
|
-
alerts: unoPorTexto(abiertas
|
|
985
|
+
alerts: conIdentidad(unoPorTexto(abiertas
|
|
536
986
|
.filter((a) => (a.module ?? "").trim() === m.module && a.plain)
|
|
537
987
|
.filter((a) => !refutada(a))
|
|
538
988
|
.map((a) => {
|
|
@@ -540,12 +990,16 @@ async function buildImpact(db, payload) {
|
|
|
540
990
|
const fuera = dondeComprobarlo(a);
|
|
541
991
|
return {
|
|
542
992
|
plain: a.plain,
|
|
993
|
+
alertIds: a.id ? [a.id] : [],
|
|
543
994
|
...(donde
|
|
544
995
|
? { donde, simbolo: (a.evidence_symbol ?? "").trim() }
|
|
545
996
|
: {}),
|
|
546
997
|
...(fuera ? { fueraDelRepo: fuera } : {}),
|
|
998
|
+
...(a.evidence_line
|
|
999
|
+
? { linea: String(a.evidence_line).trim() }
|
|
1000
|
+
: {}),
|
|
547
1001
|
};
|
|
548
|
-
}), (x) => x.plain, (x) => Boolean(x.donde || x.fueraDelRepo)),
|
|
1002
|
+
}), (x) => x.plain, (x) => Boolean(x.donde || x.fueraDelRepo)), abiertas.filter((a) => (a.module ?? "").trim() === m.module)),
|
|
549
1003
|
priorRegressions: recidivism.get(m.module) ?? 0,
|
|
550
1004
|
}))
|
|
551
1005
|
.filter(valeLaPena)
|
|
@@ -564,15 +1018,44 @@ async function buildImpact(db, payload) {
|
|
|
564
1018
|
a.module.localeCompare(b.module));
|
|
565
1019
|
if (impactos.length === 0)
|
|
566
1020
|
continue;
|
|
567
|
-
|
|
1021
|
+
lineas.push(...lineasDeImpacto(file, impactos));
|
|
568
1022
|
avisadas.push(file);
|
|
1023
|
+
for (const m of impactos.slice(0, MAX_MODULES)) {
|
|
1024
|
+
idsServidos.push(...m.alerts.flatMap((a) => a.alertIds));
|
|
1025
|
+
}
|
|
569
1026
|
}
|
|
570
1027
|
// Solo se marca como avisado lo que de verdad se dijo: si el archivo no tenía
|
|
571
1028
|
// nada hoy pero mañana sale una alerta suya, el aviso tiene que poder salir.
|
|
572
1029
|
if (avisadas.length > 0) {
|
|
573
1030
|
escribirJson(seenFile, estadoSiguiente(leerJson(seenFile), sessionId, avisadas, ahora));
|
|
574
1031
|
}
|
|
575
|
-
|
|
1032
|
+
if (lineas.length === 0)
|
|
1033
|
+
return null;
|
|
1034
|
+
// EL TECHO, sobre el total y no por fichero: una edición que toca cuatro
|
|
1035
|
+
// archivos servía cuatro bloques enteros, y el coste del canal se multiplicaba
|
|
1036
|
+
// sin que ningún tope lo viera. Ver IMPACT_BUDGET_CHARS.
|
|
1037
|
+
const texto = recortarAlPresupuesto(lineas);
|
|
1038
|
+
if (!texto)
|
|
1039
|
+
return null;
|
|
1040
|
+
// La entrega, anotada en disco. `MAX_MODULES` arriba no es cosmético: los
|
|
1041
|
+
// módulos que caen en el «+N more» NO se sirvieron, así que sus alertas no
|
|
1042
|
+
// pueden entrar en el registro de lo servido.
|
|
1043
|
+
//
|
|
1044
|
+
// headShaDe es un `git rev-parse` síncrono (~5 ms) y cabe de sobra en el
|
|
1045
|
+
// presupuesto; el hash de un texto de dos kilobytes no se mide. Lo que NO
|
|
1046
|
+
// entra aquí es la red: eso es del `--warm`.
|
|
1047
|
+
encolarEntrega(await queuePath(dir), {
|
|
1048
|
+
project_id: signals.project_id,
|
|
1049
|
+
chars_served: texto.length,
|
|
1050
|
+
...entregaDe({
|
|
1051
|
+
alertIds: idsServidos,
|
|
1052
|
+
files: avisadas,
|
|
1053
|
+
message: texto,
|
|
1054
|
+
headSha: headShaDe(dir),
|
|
1055
|
+
hash: hashDelTexto,
|
|
1056
|
+
}),
|
|
1057
|
+
});
|
|
1058
|
+
return texto;
|
|
576
1059
|
}
|
|
577
1060
|
/**
|
|
578
1061
|
* El comando. Falla abierto SIEMPRE: sin credenciales, sin red, sin proyecto,
|
package/dist/rama.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ¿Va tu rama a revertir el trabajo de otro al mergearse?
|
|
3
|
+
*
|
|
4
|
+
* Segunda de las tres comprobaciones del vigilante («¿está mi proyecto como yo
|
|
5
|
+
* creo?»), acordadas el 2026-07-26 eligiéndolas por lo que ya había dolido.
|
|
6
|
+
*
|
|
7
|
+
* ── EL INCIDENTE QUE LA PIDE ────────────────────────────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* Las PRs #358 y #359 hubo que CERRARLAS en vez de mergearlas: sus ramas venían
|
|
10
|
+
* de un `main` viejo y mergearlas habría revertido 667 y 856 líneas. Y el
|
|
11
|
+
* 2026-07-27 pasó otra vez con la #360. Ninguna de las tres avisó de nada — CI
|
|
12
|
+
* en verde las tres, porque los tests pasan perfectamente sobre un árbol viejo.
|
|
13
|
+
*
|
|
14
|
+
* ── LA SEÑAL NO ES "VAS ATRASADO" ───────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* Estar 50 commits por detrás en ficheros que no tocas es inofensivo, y avisar de
|
|
17
|
+
* eso es el ruido que enseña a ignorar los avisos. Lo que revierte trabajo es el
|
|
18
|
+
* SOLAPAMIENTO: ficheros que ha tocado tu rama Y que main ha tocado desde que os
|
|
19
|
+
* separasteis. Ahí tu versión es más vieja y al mergear gana la tuya.
|
|
20
|
+
*
|
|
21
|
+
* Es la misma lección del guardián de hoy: no avises por el módulo, avisa por el
|
|
22
|
+
* fichero.
|
|
23
|
+
*
|
|
24
|
+
* ── LO QUE NO HACE ──────────────────────────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* No hace `fetch`. Corre en el camino de un commit y una llamada de red ahí es
|
|
27
|
+
* inaceptable, así que mira el `origin/main` que ya tengas. Si no lo actualizas
|
|
28
|
+
* nunca, esto avisa de menos — nunca de más, que es el lado correcto para algo
|
|
29
|
+
* que interrumpe.
|
|
30
|
+
*/
|
|
31
|
+
import { execFile } from "node:child_process";
|
|
32
|
+
import { promisify } from "node:util";
|
|
33
|
+
const exec = promisify(execFile);
|
|
34
|
+
async function git(dir, args) {
|
|
35
|
+
const { stdout } = await exec("git", args, {
|
|
36
|
+
cwd: dir,
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
return stdout;
|
|
41
|
+
}
|
|
42
|
+
/** Cuántos ficheros se nombran antes de cortar: es una pista, no un informe. */
|
|
43
|
+
const MAX_FICHEROS = 5;
|
|
44
|
+
/**
|
|
45
|
+
* El aviso, o cadena vacía. Separado de la parte que habla con git para poder
|
|
46
|
+
* probarlo sin montar repos: mismo patrón que `guardFindings`.
|
|
47
|
+
*/
|
|
48
|
+
export function avisoDeRamaVieja(estado) {
|
|
49
|
+
if (!estado || estado.solapan.length === 0)
|
|
50
|
+
return "";
|
|
51
|
+
const { rama, base, detras, solapan } = estado;
|
|
52
|
+
const muestra = solapan.slice(0, MAX_FICHEROS).join(", ");
|
|
53
|
+
const resto = solapan.length > MAX_FICHEROS
|
|
54
|
+
? ` (+${solapan.length - MAX_FICHEROS} más)`
|
|
55
|
+
: "";
|
|
56
|
+
return (`⚠ ChangeBook — tu rama "${rama}" está ${detras} commit(s) por detrás de ${base}, ` +
|
|
57
|
+
`y ${solapan.length} fichero(s) que tocas ya cambiaron ahí: ${muestra}${resto}.\n` +
|
|
58
|
+
` Al mergear, tu versión —más vieja— gana y REVIERTE esos cambios. ` +
|
|
59
|
+
`Las PRs #358 y #359 se cerraron por esto (667 y 856 líneas).\n` +
|
|
60
|
+
` Arreglo: git fetch && git rebase ${base}`);
|
|
61
|
+
}
|
|
62
|
+
/** La rama por defecto del remoto, o `origin/main` si no se puede saber. */
|
|
63
|
+
async function baseDelRemoto(dir) {
|
|
64
|
+
try {
|
|
65
|
+
const ref = (await git(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"])).trim();
|
|
66
|
+
// refs/remotes/origin/main -> origin/main
|
|
67
|
+
const m = /^refs\/remotes\/(.+)$/.exec(ref);
|
|
68
|
+
if (m)
|
|
69
|
+
return m[1];
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Sin origin/HEAD configurado: se prueba el nombre habitual.
|
|
73
|
+
}
|
|
74
|
+
return "origin/main";
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Lee el estado de la rama. `null` cuando no aplica o no se puede saber — y esos
|
|
78
|
+
* casos son la mayoría en repos ajenos, así que fallan en SILENCIO: un guardián
|
|
79
|
+
* que grita en cada commit de quien no usa ramas se desinstala el primer día.
|
|
80
|
+
*/
|
|
81
|
+
export async function estadoDeRama(dir) {
|
|
82
|
+
let rama;
|
|
83
|
+
try {
|
|
84
|
+
rama = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (!rama || rama === "HEAD")
|
|
90
|
+
return null; // detached
|
|
91
|
+
const base = await baseDelRemoto(dir);
|
|
92
|
+
// Estar EN la rama por defecto no es ir por detrás de nadie.
|
|
93
|
+
if (base.endsWith(`/${rama}`))
|
|
94
|
+
return null;
|
|
95
|
+
let mergeBase;
|
|
96
|
+
try {
|
|
97
|
+
mergeBase = (await git(dir, ["merge-base", "HEAD", base])).trim();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null; // sin remoto, sin esa rama, o repo recién creado
|
|
101
|
+
}
|
|
102
|
+
if (!mergeBase)
|
|
103
|
+
return null;
|
|
104
|
+
let detras = 0;
|
|
105
|
+
try {
|
|
106
|
+
detras = Number((await git(dir, ["rev-list", "--count", `HEAD..${base}`])).trim());
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isFinite(detras) || detras <= 0)
|
|
112
|
+
return null;
|
|
113
|
+
// Los dos lados del triángulo, desde el punto de separación.
|
|
114
|
+
const ficheros = async (desde, hasta) => new Set((await git(dir, ["diff", "--name-only", `${desde}..${hasta}`]))
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((l) => l.trim())
|
|
117
|
+
.filter(Boolean));
|
|
118
|
+
let mios;
|
|
119
|
+
let suyos;
|
|
120
|
+
try {
|
|
121
|
+
mios = await ficheros(mergeBase, "HEAD");
|
|
122
|
+
suyos = await ficheros(mergeBase, base);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const solapan = [...mios].filter((f) => suyos.has(f)).sort();
|
|
128
|
+
return { rama, base, detras, solapan };
|
|
129
|
+
}
|
|
130
|
+
/** Lo que imprime el guardián. Cadena vacía = nada que decir. */
|
|
131
|
+
export async function avisoDeRamaPara(dir) {
|
|
132
|
+
try {
|
|
133
|
+
return avisoDeRamaVieja(await estadoDeRama(dir));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
//# sourceMappingURL=rama.js.map
|
package/dist/sync.js
CHANGED
|
@@ -427,6 +427,63 @@ const PAIR_SEP = '\u0000';
|
|
|
427
427
|
// (supabase/functions/mcp/scope.ts): si las dos implementaciones derivan, el
|
|
428
428
|
// mismo repo enseñaría acoplamientos distintos según por dónde entre el
|
|
429
429
|
// agente — y la deriva sería silenciosa.
|
|
430
|
+
/**
|
|
431
|
+
* Un fichero de pruebas, por convencion de ruta o de nombre.
|
|
432
|
+
*
|
|
433
|
+
* Se mira la RUTA y no el `domain` del modulo a proposito: el dominio es prosa
|
|
434
|
+
* que inventa el modelo, y medido en prod el 2026-07-27 llega con variantes
|
|
435
|
+
* ("Calidad y pruebas" 215 veces, pero tambien "Testing" 2, "Interfaz" e
|
|
436
|
+
* "Interfaz de Usuario" por separado). En un proyecto en ingles no funcionaria
|
|
437
|
+
* nunca. La ruta es un hecho.
|
|
438
|
+
*/
|
|
439
|
+
const ES_FICHERO_DE_PRUEBAS = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[A-Za-z0-9]+$/i;
|
|
440
|
+
/**
|
|
441
|
+
* Cuando un modulo es "casi todo tests", su co-cambio no informa de nada.
|
|
442
|
+
*
|
|
443
|
+
* POR QUE 0,8: medido sobre los 65 modulos de AppAtlas el 2026-07-27, el reparto
|
|
444
|
+
* tiene un hueco limpio y el umbral cae dentro — «Pruebas automatizadas» 93%,
|
|
445
|
+
* «Verificación manual y de consola» 81%, y el siguiente ya baja a 56%. Y esos
|
|
446
|
+
* DOS son exactamente los que producian las parejas ruidosas: «Pruebas
|
|
447
|
+
* automatizadas ↔ Radio de impacto» era, por debajo, `impact.ts ↔
|
|
448
|
+
* hookDeImpacto.test.ts` — un fichero y su propio test servido como
|
|
449
|
+
* acoplamiento de producto, 1 de las 5 parejas.
|
|
450
|
+
*
|
|
451
|
+
* Ningun modulo llega al 100%, asi que exigir 100% no filtraria nada. Y dos
|
|
452
|
+
* reglas que se probaron y se cayeron, para que nadie las reintente: por
|
|
453
|
+
* `domain` (prosa libre) y por PROMISCUIDAD — suena bien y NO discrimina:
|
|
454
|
+
* «Pruebas automatizadas» tiene 28 socios distintos, MENOS que «Análisis de
|
|
455
|
+
* cambios» (39).
|
|
456
|
+
*
|
|
457
|
+
* ESPEJO EXACTO de supabase/functions/mcp/scope.ts. Paridad en
|
|
458
|
+
* test/paridadCoCambios.test.ts.
|
|
459
|
+
*/
|
|
460
|
+
const RATIO_MODULO_DE_PRUEBAS = 0.8;
|
|
461
|
+
export function modulosDePruebas(rows) {
|
|
462
|
+
const porModulo = new Map();
|
|
463
|
+
for (const r of rows) {
|
|
464
|
+
const label = (r.module ?? '').trim();
|
|
465
|
+
if (!label || !Array.isArray(r.files))
|
|
466
|
+
continue;
|
|
467
|
+
const set = porModulo.get(label) ?? new Set();
|
|
468
|
+
for (const f of r.files) {
|
|
469
|
+
if (typeof f === 'string' && f.trim())
|
|
470
|
+
set.add(f.trim());
|
|
471
|
+
}
|
|
472
|
+
porModulo.set(label, set);
|
|
473
|
+
}
|
|
474
|
+
const fuera = new Set();
|
|
475
|
+
for (const [label, ficheros] of porModulo) {
|
|
476
|
+
if (ficheros.size === 0)
|
|
477
|
+
continue;
|
|
478
|
+
let pruebas = 0;
|
|
479
|
+
for (const f of ficheros)
|
|
480
|
+
if (ES_FICHERO_DE_PRUEBAS.test(f))
|
|
481
|
+
pruebas += 1;
|
|
482
|
+
if (pruebas / ficheros.size >= RATIO_MODULO_DE_PRUEBAS)
|
|
483
|
+
fuera.add(label);
|
|
484
|
+
}
|
|
485
|
+
return fuera;
|
|
486
|
+
}
|
|
430
487
|
export function coChangePairs(rows) {
|
|
431
488
|
const byAnalysis = new Map();
|
|
432
489
|
for (const r of rows) {
|
|
@@ -453,11 +510,16 @@ export function coChangePairs(rows) {
|
|
|
453
510
|
}
|
|
454
511
|
}
|
|
455
512
|
}
|
|
513
|
+
// Los modulos de pruebas cambian con lo que sea, por construccion: su pareja
|
|
514
|
+
// no es un acoplamiento, es la definicion de tener tests.
|
|
515
|
+
const dePruebas = modulosDePruebas(rows);
|
|
456
516
|
const pairs = [];
|
|
457
517
|
for (const [key, count] of together) {
|
|
458
518
|
if (count < MIN_PAIR_COUNT)
|
|
459
519
|
continue;
|
|
460
520
|
const [a, b] = key.split(PAIR_SEP);
|
|
521
|
+
if (dePruebas.has(a) || dePruebas.has(b))
|
|
522
|
+
continue;
|
|
461
523
|
const rate = count / Math.min(appear.get(a) ?? 1, appear.get(b) ?? 1);
|
|
462
524
|
if (rate >= MIN_PAIR_RATE)
|
|
463
525
|
pairs.push({ a, b, rate });
|
package/dist/tools.js
CHANGED
|
@@ -9,6 +9,7 @@ import { z } from "zod";
|
|
|
9
9
|
import { execFileAsync } from "./git.js";
|
|
10
10
|
import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
|
|
11
11
|
import { SupabaseError } from "./supabase.js";
|
|
12
|
+
import { coChangePairs } from "./sync.js";
|
|
12
13
|
const CHARACTER_LIMIT = 25_000;
|
|
13
14
|
/**
|
|
14
15
|
* El contrato temporal de las respuestas del atlas (benchmark 2026-07-20: el
|
|
@@ -778,9 +779,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
778
779
|
for (const f of perFile) {
|
|
779
780
|
commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
|
|
780
781
|
}
|
|
781
|
-
const [alerts, watched, recidivismRows, depsRows] = await Promise.all([
|
|
782
|
+
const [alerts, watched, recidivismRows, depsRows, coChangeRows] = await Promise.all([
|
|
782
783
|
moduleNames.length
|
|
783
|
-
? db.rest(
|
|
784
|
+
? db.rest(
|
|
785
|
+
// Con `evidence_scope`: sin él ni se refuta (alcanceDelRepo dice
|
|
786
|
+
// "sin_declarar" siempre) ni se sirve la línea NOT CHECKABLE
|
|
787
|
+
// HERE, que es la única forma de que un aviso sobre producción
|
|
788
|
+
// no se compruebe con un grep del repo. Ver guard.ts.
|
|
789
|
+
`regression_alerts?select=module,plain,evidence_symbol,evidence_expect,evidence_scope,evidence_line&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
|
|
784
790
|
pf)
|
|
785
791
|
: Promise.resolve([]),
|
|
786
792
|
// Constantes vigiladas (espejo del hospedado): el valor VIGENTE con
|
|
@@ -810,6 +816,23 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
810
816
|
.rest(`change_module?select=module,deps&deps=not.is.null&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
|
|
811
817
|
pf)
|
|
812
818
|
.catch(() => []),
|
|
819
|
+
// Co-cambio: qué se toca JUNTO en la práctica. Es la otra mitad del
|
|
820
|
+
// radio de impacto — `deps` es la arista DECLARADA (quién llama a
|
|
821
|
+
// quién) y esto es la HISTÓRICA (qué acabó cambiando a la vez), que
|
|
822
|
+
// solo sale del historial y es la señal que menos gente tiene.
|
|
823
|
+
//
|
|
824
|
+
// Es una CUARTA consulta y no se disimula. Lo que la hace aceptable es
|
|
825
|
+
// que entra en el MISMO Promise.all: en paralelo, la latencia de la
|
|
826
|
+
// tool es la de su consulta más lenta, no la suma — así que el p50
|
|
827
|
+
// solo sube si esta resulta ser la más lenta de las cuatro. Cuesta
|
|
828
|
+
// carga de servidor, no espera del agente.
|
|
829
|
+
//
|
|
830
|
+
// No se puede reutilizar la del grafo: aquella filtra `deps=not.is
|
|
831
|
+
// .null` y el co-cambio necesita TODAS las filas de la ventana.
|
|
832
|
+
db
|
|
833
|
+
.rest(`change_module?select=changelog_id,module,files&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
|
|
834
|
+
pf)
|
|
835
|
+
.catch(() => []),
|
|
813
836
|
]);
|
|
814
837
|
const watchedByFile = new Map();
|
|
815
838
|
for (const w of watched) {
|
|
@@ -860,6 +883,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
860
883
|
// dejar que se compruebe donde no era.
|
|
861
884
|
if (fuera)
|
|
862
885
|
extra += `\n → NOT CHECKABLE HERE: ${fuera}`;
|
|
886
|
+
// La linea que lo provoco. Mismas palabras que el hook (impactText).
|
|
887
|
+
if (a.evidence_line) {
|
|
888
|
+
extra += `\n → TRIGGERED BY: ${String(a.evidence_line).trim()}`;
|
|
889
|
+
}
|
|
863
890
|
porModulo.set(m, [...(porModulo.get(m) ?? []), { plain: a.plain, extra }]);
|
|
864
891
|
}
|
|
865
892
|
const alertsByModule = new Map();
|
|
@@ -871,6 +898,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
871
898
|
// Fuente única compartida (computeRecidivism) — antes 3 copias.
|
|
872
899
|
const recidivismByModule = computeRecidivism(recidivismRows);
|
|
873
900
|
const dependentsByModule = dependentsOf(moduleNames, depsRows);
|
|
901
|
+
// MISMA función que el bloque de CLAUDE.md y que el hook: una sola
|
|
902
|
+
// respuesta a "¿con qué cambia junto esto?". Sus umbrales viven en ella
|
|
903
|
+
// (3 apariciones, 60%, tope 5) y excluye los módulos de pruebas, que
|
|
904
|
+
// cambian con todo por construcción.
|
|
905
|
+
const parejas = coChangePairs(coChangeRows);
|
|
906
|
+
const coCambiosDe = (modulo) => parejas
|
|
907
|
+
.filter((p) => p.a === modulo || p.b === modulo)
|
|
908
|
+
.map((p) => ({ module: p.a === modulo ? p.b : p.a, rate: p.rate }));
|
|
874
909
|
// Ancla temporal: el último commit analizado del proyecto vs el HEAD
|
|
875
910
|
// de este árbol (misma puerta de proyecto que la refutación).
|
|
876
911
|
// Best-effort: el ancla jamás rompe la lectura que ancla.
|
|
@@ -917,6 +952,12 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
917
952
|
if (dependents?.length) {
|
|
918
953
|
lines.push(` - ↘ DEPENDS ON THIS: ${dependents.join(", ")} — check these too before you finish`);
|
|
919
954
|
}
|
|
955
|
+
// PALABRA POR PALABRA como el hook (impactText). Dos redacciones del
|
|
956
|
+
// mismo hecho son dos hechos para el agente, y aquí importa el doble
|
|
957
|
+
// porque el mismo agente ve las dos superficies en la misma sesión.
|
|
958
|
+
for (const cc of coCambiosDe(m.module)) {
|
|
959
|
+
lines.push(` - ↔ CHANGES WITH: ${cc.module} (together in ${Math.round(cc.rate * 100)}% of its changes) — check it before you finish`);
|
|
960
|
+
}
|
|
920
961
|
}
|
|
921
962
|
for (const w of watchedByFile.get(f.file) ?? []) {
|
|
922
963
|
lines.push(`- Current value: ${w.name} = ${w.value}` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "changebook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "io.github.raulbr90/changebook",
|
|
5
5
|
"description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.raulbr90/changebook",
|
|
4
4
|
"description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.5.0",
|
|
6
6
|
"websiteUrl": "https://changebook.dev",
|
|
7
7
|
"remotes": [
|
|
8
8
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
17
17
|
"identifier": "changebook",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.5.0",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|