changebook 0.5.0 → 0.7.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/aciertos.js +100 -0
- package/dist/aliasDeModulo.js +203 -0
- package/dist/badge.js +159 -0
- package/dist/context.js +7 -1
- package/dist/guard.js +96 -13
- package/dist/hookMudo.js +172 -0
- package/dist/impact.js +319 -25
- package/dist/import.js +26 -1
- package/dist/index.js +173 -5
- package/dist/pregunta.js +59 -0
- package/dist/reglas.js +299 -0
- package/dist/scan.js +427 -0
- package/dist/supabase.js +84 -1
- package/dist/sync.js +291 -24
- package/dist/tools.js +182 -114
- package/package.json +2 -2
- package/server.json +3 -3
package/dist/hookMudo.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { settingsHookInstalled } from './context.js';
|
|
3
|
+
import { gitPath } from './git.js';
|
|
4
|
+
import { IMPACT_HOOK } from './impact.js';
|
|
5
|
+
/**
|
|
6
|
+
* C6 · Un hook que se rompe deja de romperse en silencio.
|
|
7
|
+
*
|
|
8
|
+
* POR QUÉ, y es el fallo de toda esta categoría de producto: `hook-impact` es de
|
|
9
|
+
* RETENCIÓN PASIVA. Una vez instalado, el usuario no tiene que acordarse de
|
|
10
|
+
* nada, y por eso **un hook desinstalado se ve exactamente igual que un repo
|
|
11
|
+
* tranquilo**: los dos son silencio. Es la invariante 17 del repo, y este
|
|
12
|
+
* fichero es su caso más caro — el que le pasa a los usuarios y no al dueño, que
|
|
13
|
+
* desinstalan sin saber por qué dejó de servirles.
|
|
14
|
+
*
|
|
15
|
+
* SE MIDEN DOS COSAS INDEPENDIENTES, y esa es toda la gracia:
|
|
16
|
+
*
|
|
17
|
+
* `declarado` ¿sigue el hook en `.claude/settings.json`?
|
|
18
|
+
* `ultimaSenal` ¿cuándo dejó rastro por última vez?
|
|
19
|
+
*
|
|
20
|
+
* Cruzarlas separa cuatro situaciones que un solo dato confunde:
|
|
21
|
+
*
|
|
22
|
+
* declarado + fresco → sano. SILENCIO ABSOLUTO.
|
|
23
|
+
* declarado + rancio → está puesto y no corre. Lo más difícil de ver solo.
|
|
24
|
+
* sin declarar + hubo → alguien lo quitó. El caso de la ficha.
|
|
25
|
+
* sin declarar + nunca → no se instaló jamás. NO es una rotura, y avisar aquí
|
|
26
|
+
* convertiría este canal en publicidad. Se calla.
|
|
27
|
+
*
|
|
28
|
+
* La última línea es la que decide si esto sirve: un aviso que también sale
|
|
29
|
+
* cuando no pasa nada es ruido, y el ruido enseña a ignorar el canal — que es
|
|
30
|
+
* justo lo que hace que el hook se desinstale.
|
|
31
|
+
*/
|
|
32
|
+
/** El rastro se considera vivo si es de los últimos 14 días (ficha C6). */
|
|
33
|
+
export const DIAS_SIN_SENAL = 14;
|
|
34
|
+
/** Cada cuánto se repite un aviso que sigue vigente. */
|
|
35
|
+
const DIAS_ENTRE_AVISOS = 30;
|
|
36
|
+
/**
|
|
37
|
+
* Los cuatro estados. Pura a propósito: la tabla de verdad es lo que hay que
|
|
38
|
+
* poder probar, y leer ficheros dentro la haría dependiente del disco.
|
|
39
|
+
*/
|
|
40
|
+
export function estadoDelHook(h) {
|
|
41
|
+
const dias = h.ultimaSenal === null
|
|
42
|
+
? Infinity
|
|
43
|
+
: (h.ahora - h.ultimaSenal) / 86_400_000;
|
|
44
|
+
if (h.declarado)
|
|
45
|
+
return dias <= DIAS_SIN_SENAL ? 'sano' : 'declarado-mudo';
|
|
46
|
+
// Sin declarar: lo que distingue «lo quitaron» de «nunca lo hubo» es que
|
|
47
|
+
// exista rastro, no cuánto de viejo sea. Un repo con el hook retirado hace
|
|
48
|
+
// seis meses sigue siendo un repo al que le quitaron el hook.
|
|
49
|
+
return h.ultimaSenal === null ? 'nunca' : 'retirado';
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* El texto. Cadena vacía cuando no hay nada que decir — un hook sano tiene que
|
|
53
|
+
* callar del todo, igual que `feedWarning`.
|
|
54
|
+
*/
|
|
55
|
+
export function avisoDeHookMudo(estado, opts = {}) {
|
|
56
|
+
const { audience = 'human', dias } = opts;
|
|
57
|
+
if (estado === 'sano' || estado === 'nunca')
|
|
58
|
+
return '';
|
|
59
|
+
const cuanto = typeof dias === 'number' && Number.isFinite(dias)
|
|
60
|
+
? ` (último rastro hace ${Math.floor(dias)} días)`
|
|
61
|
+
: '';
|
|
62
|
+
if (estado === 'retirado') {
|
|
63
|
+
const que = `El hook de ChangeBook que avisa ANTES de editar ya no está en .claude/settings.json${cuanto}.`;
|
|
64
|
+
return audience === 'agent'
|
|
65
|
+
? [
|
|
66
|
+
'⚠ ChangeBook: el aviso previo a editar está retirado en este repo.',
|
|
67
|
+
`${que} Vas a editar sin saber quién depende de cada fichero: dilo antes de dar por bueno un cambio grande.`,
|
|
68
|
+
'Si el dueño lo quiere de vuelta: `changebook hook-impact install`.',
|
|
69
|
+
].join('\n')
|
|
70
|
+
: [
|
|
71
|
+
'\n⚠ ChangeBook: el aviso previo a editar está retirado.',
|
|
72
|
+
` ${que}`,
|
|
73
|
+
' Si lo quitaste a propósito, ignora esto — no se repetirá.',
|
|
74
|
+
' Para volver a ponerlo: changebook hook-impact install\n',
|
|
75
|
+
].join('\n');
|
|
76
|
+
}
|
|
77
|
+
// declarado-mudo: lo más difícil de ver solo, porque el fichero DICE que está.
|
|
78
|
+
const que = `Está declarado en .claude/settings.json pero no ha dejado rastro${cuanto}.`;
|
|
79
|
+
return audience === 'agent'
|
|
80
|
+
? [
|
|
81
|
+
'⚠ ChangeBook: el aviso previo a editar está puesto pero no corre.',
|
|
82
|
+
`${que} O sea que lleva ese tiempo sin decirte quién depende de lo que tocas — no lo tomes como que no había nada que decir.`,
|
|
83
|
+
'Díselo al dueño; se comprueba con `changebook hook-impact status`.',
|
|
84
|
+
].join('\n')
|
|
85
|
+
: [
|
|
86
|
+
'\n⚠ ChangeBook: el aviso previo a editar está puesto pero no corre.',
|
|
87
|
+
` ${que}`,
|
|
88
|
+
' No es lo mismo que "no había nada que avisar": lleva ese tiempo mudo.',
|
|
89
|
+
' Compruébalo con: changebook hook-impact status\n',
|
|
90
|
+
].join('\n');
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Leer y escribir la marca. Locales y no importados de `impact.ts`: sus
|
|
94
|
+
* `leerJson`/`escribirJson` son privados de aquel módulo, y exportarlos solo
|
|
95
|
+
* para esto ensancharía su superficie pública por comodidad.
|
|
96
|
+
*/
|
|
97
|
+
function leerMarca(file) {
|
|
98
|
+
if (!file)
|
|
99
|
+
return null;
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function escribirMarca(file, marca) {
|
|
108
|
+
if (!file)
|
|
109
|
+
return;
|
|
110
|
+
try {
|
|
111
|
+
fs.writeFileSync(file, JSON.stringify(marca));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Sin sitio donde anotar, el aviso se repetirá. Es el lado seguro.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function marcaPath(dir) {
|
|
118
|
+
return gitPath(dir, 'changebook-hook-mudo.json').catch(() => null);
|
|
119
|
+
}
|
|
120
|
+
/** El rastro del hook: la marca de tiempo del acumulado de silencios. */
|
|
121
|
+
async function ultimaSenal(dir) {
|
|
122
|
+
const file = await gitPath(dir, 'changebook-impact-silencio.json').catch(() => null);
|
|
123
|
+
if (!file)
|
|
124
|
+
return null;
|
|
125
|
+
try {
|
|
126
|
+
return fs.statSync(file).mtimeMs;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null; // No existe: nunca dejó rastro.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* El aviso para un directorio, y la marca para no repetirlo.
|
|
134
|
+
*
|
|
135
|
+
* SE AVISA UNA VEZ POR EPISODIO, no una vez y nunca más: si el estado cambia
|
|
136
|
+
* —se retiró y luego se quedó mudo, o volvió a romperse tras arreglarse— eso es
|
|
137
|
+
* un suceso nuevo y merece decirse. Y si el mismo estado sigue vigente 30 días
|
|
138
|
+
* después, se repite: callar para siempre convierte «no molestar» en «no
|
|
139
|
+
* enterarse», que es el fallo que este fichero existe para arreglar.
|
|
140
|
+
*
|
|
141
|
+
* Best-effort de principio a fin: esto corre dentro del pre-commit y del arranque
|
|
142
|
+
* de sesión, y ninguno de los dos puede caerse porque no se pueda leer un JSON.
|
|
143
|
+
*/
|
|
144
|
+
export async function avisoDeHookMudoPara(dir, opts = {}) {
|
|
145
|
+
try {
|
|
146
|
+
const ahora = Date.now();
|
|
147
|
+
const senal = await ultimaSenal(dir);
|
|
148
|
+
const estado = estadoDelHook({
|
|
149
|
+
declarado: settingsHookInstalled(dir, IMPACT_HOOK),
|
|
150
|
+
ultimaSenal: senal,
|
|
151
|
+
ahora,
|
|
152
|
+
});
|
|
153
|
+
if (estado === 'sano' || estado === 'nunca')
|
|
154
|
+
return '';
|
|
155
|
+
const file = await marcaPath(dir);
|
|
156
|
+
const previa = leerMarca(file);
|
|
157
|
+
if (previa?.estado === estado &&
|
|
158
|
+
ahora - previa.en < DIAS_ENTRE_AVISOS * 86_400_000) {
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
escribirMarca(file, { estado, en: ahora });
|
|
162
|
+
return avisoDeHookMudo(estado, {
|
|
163
|
+
...opts,
|
|
164
|
+
dias: senal === null ? null : (ahora - senal) / 86_400_000,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Un aviso que no se puede calcular no puede romper el commit.
|
|
169
|
+
return '';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=hookMudo.js.map
|
package/dist/impact.js
CHANGED
|
@@ -43,6 +43,7 @@ import * as path from "node:path";
|
|
|
43
43
|
import { presupuestoDeTiempo } from "./context.js";
|
|
44
44
|
import { execFileAsync, gitPath, projectNameFor } from "./git.js";
|
|
45
45
|
import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, entregaDe, ficherosEnRepo, hashDelTexto, headShaDe, slugifyProject, unoPorTexto, } from "./guard.js";
|
|
46
|
+
import { aliasesFor, canonicalizeModuleRows, } from "./aliasDeModulo.js";
|
|
46
47
|
import { coChangePairs } from "./sync.js";
|
|
47
48
|
import { computeRecidivism, dependentsOf } from "./tools.js";
|
|
48
49
|
/**
|
|
@@ -544,6 +545,36 @@ const QUEUE_KEEP_BYTES = 32_768;
|
|
|
544
545
|
async function queuePath(dir) {
|
|
545
546
|
return gitPath(dir, "changebook-impact-queue.jsonl").catch(() => null);
|
|
546
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* El acumulado de la tasa de silencio. Fichero aparte de la cola y con otra
|
|
550
|
+
* vida: la cola se vacía en cada drenado, esto NO se trunca ni se sube nunca.
|
|
551
|
+
*
|
|
552
|
+
* Local a propósito, y esa es la decisión de diseño, no una limitación. Una
|
|
553
|
+
* medición vale por el tiempo que lleva acumulando: un contador local empieza a
|
|
554
|
+
* contar esta noche, y un panel con migración empieza a contar dentro de dos
|
|
555
|
+
* días. Cuando haya semanas de número propio se sabrá qué forma debe tener el
|
|
556
|
+
* dato en el servidor, porque se habrá mirado — diseñar el esquema antes de ver
|
|
557
|
+
* un solo número es el orden equivocado.
|
|
558
|
+
*/
|
|
559
|
+
async function tallyPath(dir) {
|
|
560
|
+
return gitPath(dir, "changebook-impact-silencio.json").catch(() => null);
|
|
561
|
+
}
|
|
562
|
+
/** Suma y guarda. Best-effort como todo lo de este fichero. */
|
|
563
|
+
function acumularSilencios(file, nuevo) {
|
|
564
|
+
if (!file)
|
|
565
|
+
return;
|
|
566
|
+
try {
|
|
567
|
+
const previo = leerJson(file);
|
|
568
|
+
fs.writeFileSync(file, `${JSON.stringify(tallyActualizado(previo, nuevo), null, 2)}\n`);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// Un contador que no se puede escribir no puede tumbar el drenado.
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/** Lo acumulado hasta ahora, para el comando que lo enseña. */
|
|
575
|
+
export async function silenciosAcumulados(dir) {
|
|
576
|
+
return leerJson(await tallyPath(dir)) ?? {};
|
|
577
|
+
}
|
|
547
578
|
/**
|
|
548
579
|
* Anota una entrega. JSONL y no JSON: un append no tiene que leer lo que ya hay,
|
|
549
580
|
* y dos procesos solapados (dos ediciones seguidas) no pueden pisarse el fichero
|
|
@@ -553,6 +584,10 @@ async function queuePath(dir) {
|
|
|
553
584
|
* edición que la provocó.
|
|
554
585
|
*/
|
|
555
586
|
export function encolarEntrega(file, entrega) {
|
|
587
|
+
anexarALaCola(file, entrega);
|
|
588
|
+
}
|
|
589
|
+
/** El append en sí, compartido por la entrega y por el silencio. */
|
|
590
|
+
function anexarALaCola(file, linea) {
|
|
556
591
|
if (!file)
|
|
557
592
|
return;
|
|
558
593
|
try {
|
|
@@ -570,13 +605,23 @@ export function encolarEntrega(file, entrega) {
|
|
|
570
605
|
catch {
|
|
571
606
|
// Aún no existe: nada que recortar.
|
|
572
607
|
}
|
|
573
|
-
fs.appendFileSync(file, `${JSON.stringify(
|
|
608
|
+
fs.appendFileSync(file, `${JSON.stringify(linea)}\n`);
|
|
574
609
|
}
|
|
575
610
|
catch {
|
|
576
611
|
// Sin sitio donde anotar, el hook sigue avisando igual. El registro es una
|
|
577
612
|
// segunda lectura del mismo hecho, nunca el hecho.
|
|
578
613
|
}
|
|
579
614
|
}
|
|
615
|
+
/** Los dos motivos que responden «¿es ruidoso el hook?». El resto describe por
|
|
616
|
+
* qué no pudo contestar, que es otra pregunta. */
|
|
617
|
+
export const MOTIVOS_DE_PUNTERIA = [
|
|
618
|
+
"fuera-de-modulo",
|
|
619
|
+
"sin-lineas",
|
|
620
|
+
];
|
|
621
|
+
/** Anota una edición en la que el hook no dijo nada. Un append y nada más. */
|
|
622
|
+
export function anotarSilencio(file, muda) {
|
|
623
|
+
anexarALaCola(file, { muda });
|
|
624
|
+
}
|
|
580
625
|
/**
|
|
581
626
|
* Las entregas legibles de la cola. Las líneas rotas se tiran en silencio: la
|
|
582
627
|
* primera puede venir cortada por el recorte, y una entrega ilegible no es un
|
|
@@ -601,6 +646,130 @@ export function entregasDeLaCola(contenido) {
|
|
|
601
646
|
}
|
|
602
647
|
return fuera;
|
|
603
648
|
}
|
|
649
|
+
/**
|
|
650
|
+
* Cuenta las líneas mudas de la cola, por motivo. Pura, y separada de
|
|
651
|
+
* `entregasDeLaCola` a propósito: aquella devuelve lo que se SUBE, ésta lo que
|
|
652
|
+
* solo se cuenta. Un motivo desconocido se ignora en vez de crear una categoría
|
|
653
|
+
* nueva por una línea corrupta.
|
|
654
|
+
*/
|
|
655
|
+
export function silenciosDeLaCola(contenido) {
|
|
656
|
+
const cuenta = {};
|
|
657
|
+
const conocidos = new Set([
|
|
658
|
+
"fuera-de-modulo",
|
|
659
|
+
"sin-lineas",
|
|
660
|
+
"ya-avisado",
|
|
661
|
+
"sin-atlas",
|
|
662
|
+
"fuera-del-repo",
|
|
663
|
+
"timeout",
|
|
664
|
+
"error",
|
|
665
|
+
]);
|
|
666
|
+
for (const linea of contenido.split("\n")) {
|
|
667
|
+
if (!linea.trim())
|
|
668
|
+
continue;
|
|
669
|
+
try {
|
|
670
|
+
const e = JSON.parse(linea);
|
|
671
|
+
if (typeof e?.muda === "string" && conocidos.has(e.muda)) {
|
|
672
|
+
const k = e.muda;
|
|
673
|
+
cuenta[k] = (cuenta[k] ?? 0) + 1;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
// Línea partida por el recorte, o basura. Se ignora, igual que arriba.
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return cuenta;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Los avisos que el grep tumbó, sacados de la cola. Pura, como las otras dos.
|
|
684
|
+
*
|
|
685
|
+
* Tercer tipo de línea del mismo fichero: `advice_hash` es una entrega, `muda`
|
|
686
|
+
* un silencio y `refutados` un veredicto del grep. Se filtra por forma de UUID
|
|
687
|
+
* porque el PATCH va contra `id=in.(...)`: una línea corrupta que colara texto
|
|
688
|
+
* arbitrario ahí es la diferencia entre un 400 y algo peor.
|
|
689
|
+
*/
|
|
690
|
+
export function refutacionesDeLaCola(contenido) {
|
|
691
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
692
|
+
const fuera = new Set();
|
|
693
|
+
for (const linea of contenido.split("\n")) {
|
|
694
|
+
if (!linea.trim())
|
|
695
|
+
continue;
|
|
696
|
+
try {
|
|
697
|
+
const e = JSON.parse(linea);
|
|
698
|
+
if (!Array.isArray(e?.refutados))
|
|
699
|
+
continue;
|
|
700
|
+
for (const id of e.refutados) {
|
|
701
|
+
if (typeof id === "string" && UUID.test(id))
|
|
702
|
+
fuera.add(id);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
// Línea partida por el recorte, o basura. Se ignora, igual que arriba.
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return [...fuera];
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Suma un recuento nuevo al acumulado. Pura para poder probar la aritmética sin
|
|
713
|
+
* disco: es un acumulador que NO se trunca nunca, así que un error aquí no se
|
|
714
|
+
* arregla con la siguiente ronda como sí pasa con la cola.
|
|
715
|
+
*/
|
|
716
|
+
export function tallyActualizado(previo, nuevo) {
|
|
717
|
+
const fuera = { ...(previo ?? {}) };
|
|
718
|
+
for (const [k, v] of Object.entries(nuevo)) {
|
|
719
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
|
|
720
|
+
continue;
|
|
721
|
+
fuera[k] = (fuera[k] ?? 0) + v;
|
|
722
|
+
}
|
|
723
|
+
return fuera;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* La tasa, con los dos números crudos a la vista. El porcentaje solo miente por
|
|
727
|
+
* omisión: 90 % sobre 10 ediciones y 90 % sobre 900 son cosas distintas.
|
|
728
|
+
*
|
|
729
|
+
* DENOMINADOR: solo las ediciones en las que el hook PUDO contestar, o sea las
|
|
730
|
+
* que tenían atlas y un fichero dentro del repo. Meter `sin-atlas` o
|
|
731
|
+
* `ya-avisado` aquí subiría la tasa sin que la puntería hubiera mejorado, que
|
|
732
|
+
* es la forma más fácil de fabricar un número bonito.
|
|
733
|
+
*/
|
|
734
|
+
export function tasaDeSilencio(cuenta) {
|
|
735
|
+
const calladas = MOTIVOS_DE_PUNTERIA.reduce((a, m) => a + (cuenta[m] ?? 0), 0);
|
|
736
|
+
const hablo = cuenta.hablo ?? 0;
|
|
737
|
+
return { calladas, total: calladas + hablo, hablo };
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* El informe que imprime `changebook silence`.
|
|
741
|
+
*
|
|
742
|
+
* LOS DOS NÚMEROS CRUDOS SIEMPRE, y el porcentaje después. Un 90 % sobre 10
|
|
743
|
+
* ediciones y un 90 % sobre 900 son afirmaciones distintas, y la que se toma
|
|
744
|
+
* por buena decide si se toca el generador de avisos o no. Con el denominador a
|
|
745
|
+
* la vista, quien lo lee sabe cuánto pesa.
|
|
746
|
+
*/
|
|
747
|
+
export function informeDeSilencio(cuenta) {
|
|
748
|
+
const { calladas, total, hablo } = tasaDeSilencio(cuenta);
|
|
749
|
+
const fuera = [
|
|
750
|
+
"ya-avisado",
|
|
751
|
+
"sin-atlas",
|
|
752
|
+
"fuera-del-repo",
|
|
753
|
+
"timeout",
|
|
754
|
+
"error",
|
|
755
|
+
];
|
|
756
|
+
const l = ["Silencio del hook PreToolUse, este repo:", ""];
|
|
757
|
+
if (total === 0) {
|
|
758
|
+
l.push(" Todavía no hay ninguna edición con contexto que contar.", " El contador lo llena `impact --warm` al drenar la cola; edita algo y", " vuelve dentro de unos minutos.");
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
const pct = Math.round((calladas / total) * 1000) / 10;
|
|
762
|
+
l.push(` ediciones con contexto ${String(total).padStart(6)}`, ` de ellas, calló ${String(calladas).padStart(6)} ${pct}%`, ` de ellas, avisó ${String(hablo).padStart(6)}`, "", " por qué calló:", ...MOTIVOS_DE_PUNTERIA.map((m) => ` ${m.padEnd(20)} ${String(cuenta[m] ?? 0).padStart(6)}`));
|
|
763
|
+
}
|
|
764
|
+
const otras = fuera.filter((m) => (cuenta[m] ?? 0) > 0);
|
|
765
|
+
if (otras.length > 0) {
|
|
766
|
+
l.push("", " fuera del cómputo (no miden puntería, describen por qué no pudo):", ...otras.map((m) => ` ${m.padEnd(20)} ${String(cuenta[m] ?? 0).padStart(6)}`));
|
|
767
|
+
}
|
|
768
|
+
if (total > 0) {
|
|
769
|
+
l.push("", " Sano entre 85% y 95%. Por debajo del 80%, el hook es ruido.");
|
|
770
|
+
}
|
|
771
|
+
return l.join("\n");
|
|
772
|
+
}
|
|
604
773
|
/**
|
|
605
774
|
* Las que TODAVÍA no están registradas, por (advice_hash, head_sha).
|
|
606
775
|
*
|
|
@@ -648,6 +817,40 @@ async function drenarCola(db, dir) {
|
|
|
648
817
|
return; // No hay cola: el caso normal.
|
|
649
818
|
}
|
|
650
819
|
const cola = entregasDeLaCola(contenido);
|
|
820
|
+
// EL RECUENTO, ANTES DE TOCAR LA RED Y ANTES DE BORRAR NADA. Las mudas se
|
|
821
|
+
// cuentan aquí y se tiran: NO suben a `atlas_reads`, porque una edición
|
|
822
|
+
// silenciosa no es una consulta al atlas y el rollup mensual barre por
|
|
823
|
+
// `advice_hash is null` — subirlas inflaría `reads`, que es literalmente el
|
|
824
|
+
// número que dice «tu agente consultó el atlas N veces».
|
|
825
|
+
//
|
|
826
|
+
// Va en el drenado y no en el camino caliente a propósito: esto ya corre
|
|
827
|
+
// desacoplado (`impact --warm`) y ya paga red, así que la edición solo gana
|
|
828
|
+
// un append. Si el proceso muere entre acumular y borrar la cola, la ronda
|
|
829
|
+
// siguiente vuelve a contar esas líneas: se prefiere sobrecontar un número
|
|
830
|
+
// local a perder ediciones del denominador, y se dice aquí en vez de fingir
|
|
831
|
+
// que es exacto.
|
|
832
|
+
acumularSilencios(await tallyPath(dir), { ...silenciosDeLaCola(contenido), hablo: cola.length });
|
|
833
|
+
// LOS VEREDICTOS DEL GREP, antes del corto que borra la cola cuando no hay
|
|
834
|
+
// entregas: una ronda puede refutar sin servir texto, y ese veredicto es
|
|
835
|
+
// exactamente el que llevaba meses perdiéndose.
|
|
836
|
+
//
|
|
837
|
+
// `resolved_at=is.null` en el filtro hace el PATCH idempotente sin preguntar
|
|
838
|
+
// primero: si la alerta ya se cerró —por una persona, por el agente o por una
|
|
839
|
+
// ronda anterior— este UPDATE no toca ninguna fila, así que el grep NUNCA
|
|
840
|
+
// pisa un juicio de otro. Reenviar sale gratis, igual que en las entregas.
|
|
841
|
+
const refutados = refutacionesDeLaCola(contenido);
|
|
842
|
+
if (refutados.length > 0) {
|
|
843
|
+
await db
|
|
844
|
+
.patchRows("regression_alerts", `id=in.(${refutados.join(",")})&resolved_at=is.null`, {
|
|
845
|
+
resolved_at: new Date().toISOString(),
|
|
846
|
+
resolution: "dismissed",
|
|
847
|
+
resolution_by: "grep",
|
|
848
|
+
})
|
|
849
|
+
.catch(() => {
|
|
850
|
+
// Best-effort, como todo lo del hook: el veredicto se pierde y la
|
|
851
|
+
// siguiente ronda lo vuelve a calcular, porque la alerta sigue abierta.
|
|
852
|
+
});
|
|
853
|
+
}
|
|
651
854
|
if (cola.length === 0) {
|
|
652
855
|
try {
|
|
653
856
|
fs.rmSync(file, { force: true });
|
|
@@ -741,7 +944,10 @@ async function fetchAtlas(db, dir, env) {
|
|
|
741
944
|
//
|
|
742
945
|
// Y las alertas se traen SIN filtrar por resolved_at, porque las abiertas y la
|
|
743
946
|
// reincidencia (all-time) salen del mismo conjunto.
|
|
744
|
-
|
|
947
|
+
// Los alias entran en el MISMO Promise.all: el canal del guardian corre en
|
|
948
|
+
// CADA edicion y no puede pagar una ronda de red mas. Se canonicaliza abajo,
|
|
949
|
+
// antes de que coChangePairs/computeRecidivism/dependentsOf agrupen por nombre.
|
|
950
|
+
const [rows, deps, alerts, { aliases }] = await Promise.all([
|
|
745
951
|
db
|
|
746
952
|
.rest(
|
|
747
953
|
// `changelog_id` para el co-cambio (ver ImpactCache["rows"]). Va en la
|
|
@@ -760,16 +966,20 @@ async function fetchAtlas(db, dir, env) {
|
|
|
760
966
|
// hace falta para poder atribuir después "se te avisó de esto" a la fila
|
|
761
967
|
// que lo dijo. Vienen en la misma consulta porque son gratis aquí y una
|
|
762
968
|
// 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}`)
|
|
969
|
+
`regression_alerts?select=id,module,plain,resolution,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}`)
|
|
764
970
|
.catch(() => []),
|
|
971
|
+
aliasesFor(db, projectId),
|
|
765
972
|
]);
|
|
973
|
+
// La cache se guarda YA CANONICALIZADA: todo lo que la lee (co-cambio,
|
|
974
|
+
// reincidencia, dependientes, el aviso del guardian) agrupa por nombre, y
|
|
975
|
+
// resolver en cada lector seria la cuarta copia de la misma regla.
|
|
766
976
|
return {
|
|
767
977
|
v: IMPACT_CACHE_V,
|
|
768
978
|
fetched_at: Date.now(),
|
|
769
979
|
project_id: projectId,
|
|
770
|
-
rows,
|
|
771
|
-
deps,
|
|
772
|
-
alerts,
|
|
980
|
+
rows: canonicalizeModuleRows(rows, aliases),
|
|
981
|
+
deps: canonicalizeModuleRows(deps, aliases),
|
|
982
|
+
alerts: canonicalizeModuleRows(alerts, aliases),
|
|
773
983
|
};
|
|
774
984
|
}
|
|
775
985
|
/**
|
|
@@ -883,37 +1093,62 @@ async function readStdin(limit = 256 * 1024) {
|
|
|
883
1093
|
}
|
|
884
1094
|
return Buffer.concat(chunks).toString("utf8");
|
|
885
1095
|
}
|
|
886
|
-
async function buildImpact(db, payload
|
|
1096
|
+
async function buildImpact(db, payload,
|
|
1097
|
+
/** Una edición, un apunte. Lo comparte con printImpact, que anota `timeout` y
|
|
1098
|
+
* `error` desde fuera; sin él, una carrera perdida se contaría dos veces. */
|
|
1099
|
+
marca = { contado: false }) {
|
|
887
1100
|
const rutas = pathsFromPayload(payload);
|
|
1101
|
+
// Sin ruta no hay edición de fichero que contar: no entra ni en el numerador
|
|
1102
|
+
// ni en el denominador. Un payload sin `file_path` no es un silencio del
|
|
1103
|
+
// hook, es una llamada que no iba con él.
|
|
888
1104
|
if (rutas.length === 0)
|
|
889
1105
|
return null;
|
|
890
1106
|
const dir = payload.cwd?.trim() || process.cwd();
|
|
891
1107
|
// El toplevel de git, no el cwd: el agente puede estar en un subdirectorio y
|
|
892
1108
|
// el atlas guarda rutas relativas a la raíz del repo.
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1109
|
+
//
|
|
1110
|
+
// La ruta de la cola se resuelve EN PARALELO, no después: es otro
|
|
1111
|
+
// `git rev-parse` y en serie costaría su latencia entera a cada edición
|
|
1112
|
+
// silenciosa, que son la mayoría. Concurrente no cuesta reloj.
|
|
1113
|
+
const [toplevel, colaFile] = await Promise.all([
|
|
1114
|
+
execFileAsync("git", ["rev-parse", "--show-toplevel"], {
|
|
1115
|
+
cwd: dir,
|
|
1116
|
+
encoding: "utf8",
|
|
1117
|
+
})
|
|
1118
|
+
.then(({ stdout }) => stdout.trim())
|
|
1119
|
+
.catch(() => dir),
|
|
1120
|
+
queuePath(dir),
|
|
1121
|
+
]);
|
|
1122
|
+
/** Anota por qué se calló y devuelve el `null` que ya devolvía. */
|
|
1123
|
+
const callar = (muda) => {
|
|
1124
|
+
if (marca.contado)
|
|
1125
|
+
return null;
|
|
1126
|
+
marca.contado = true;
|
|
1127
|
+
anotarSilencio(colaFile, muda);
|
|
1128
|
+
return null;
|
|
1129
|
+
};
|
|
899
1130
|
const relativas = [
|
|
900
1131
|
...new Set(rutas
|
|
901
1132
|
.map((r) => repoRelative(toplevel, r))
|
|
902
1133
|
.filter((r) => Boolean(r))),
|
|
903
1134
|
];
|
|
904
1135
|
if (relativas.length === 0)
|
|
905
|
-
return
|
|
1136
|
+
return callar("fuera-del-repo");
|
|
906
1137
|
const ahora = Date.now();
|
|
907
1138
|
const sessionId = payload.session_id?.trim() || "sin-sesion";
|
|
908
1139
|
const seenFile = await gitPath(dir, "changebook-impact-seen.json").catch(() => null);
|
|
909
1140
|
const yaAvisadas = rutasYaAvisadas(leerJson(seenFile), sessionId, ahora);
|
|
910
1141
|
const pendientes = relativas.filter((r) => !yaAvisadas.has(r));
|
|
911
1142
|
if (pendientes.length === 0)
|
|
912
|
-
return
|
|
1143
|
+
return callar("ya-avisado");
|
|
913
1144
|
const signals = await atlasSignals(db, dir);
|
|
914
1145
|
if (!signals?.project_id)
|
|
915
|
-
return
|
|
916
|
-
|
|
1146
|
+
return callar("sin-atlas");
|
|
1147
|
+
// `resolution` puede faltar si estas alertas vienen de la caché en disco de
|
|
1148
|
+
// una versión anterior, que no la guardaba. Ausente → `null` → NO cuenta como
|
|
1149
|
+
// confirmada. Es el lado seguro: una caché vieja hace que el hook diga «este
|
|
1150
|
+
// módulo no tiene antecedentes» de menos, nunca de más.
|
|
1151
|
+
const recidivism = computeRecidivism(signals.alerts.map((a) => ({ ...a, resolution: a.resolution ?? null })));
|
|
917
1152
|
const abiertas = signals.alerts.filter((a) => !a.resolved_at);
|
|
918
1153
|
// Refutación al servir, igual que en atlas_file_context: el mismo grep que
|
|
919
1154
|
// corre el guardián en el pre-commit. De 7 alertas abiertas, 4 se caían con
|
|
@@ -931,13 +1166,34 @@ async function buildImpact(db, payload) {
|
|
|
931
1166
|
// sobre el mismo simbolo y solo uno de los dos aplica a cada aviso: 'present'
|
|
932
1167
|
// se refuta, 'absent' se localiza (ver avisoRefutado). Nunca se gastan dos.
|
|
933
1168
|
let grepsRestantes = MAX_REFUTACIONES;
|
|
1169
|
+
/**
|
|
1170
|
+
* LOS VEREDICTOS DEL GREP, QUE HASTA HOY SE TIRABAN.
|
|
1171
|
+
*
|
|
1172
|
+
* `refutada` decidía si el aviso se enseña y ahí se acababa: el veredicto no
|
|
1173
|
+
* salía de este proceso. Medido el 02/08 en producción: de 119 avisos, 48
|
|
1174
|
+
* traen símbolo, 30 son del tipo comprobable y 28 caen dentro de lo que este
|
|
1175
|
+
* grep puede juzgar — uno de cada cuatro, a la basura. Y mientras tanto el
|
|
1176
|
+
* producto publicaba «100% de acierto» porque el lado `refutado` estaba vacío:
|
|
1177
|
+
* marcar acierto le cuesta al agente una llamada, y refutar exigía a una
|
|
1178
|
+
* persona.
|
|
1179
|
+
*
|
|
1180
|
+
* Este juez es el que hacía falta y ya estaba aquí: NO es un modelo. Cuenta
|
|
1181
|
+
* apariciones del símbolo en el árbol de trabajo. No es el autor del aviso
|
|
1182
|
+
* —ése es un modelo del servidor que solo vio un diff—, no opina y no tiene
|
|
1183
|
+
* interés en el resultado. Por eso se guarda firmado como `grep` y nunca se
|
|
1184
|
+
* suma con los otros dos jueces.
|
|
1185
|
+
*/
|
|
1186
|
+
const refutados = [];
|
|
934
1187
|
const refutada = (a) => {
|
|
935
1188
|
if (a.evidence_expect !== "present")
|
|
936
1189
|
return false;
|
|
937
1190
|
if (grepsRestantes <= 0)
|
|
938
1191
|
return false;
|
|
939
1192
|
grepsRestantes -= 1;
|
|
940
|
-
|
|
1193
|
+
const cae = avisoRefutado(a, (s) => contarEnRepo(toplevel, s));
|
|
1194
|
+
if (cae && a.id)
|
|
1195
|
+
refutados.push(a.id);
|
|
1196
|
+
return cae;
|
|
941
1197
|
};
|
|
942
1198
|
/** Donde sigue apareciendo el simbolo de un aviso 'absent'. Contesta el condicional. */
|
|
943
1199
|
const localizar = (a) => {
|
|
@@ -966,10 +1222,15 @@ async function buildImpact(db, payload) {
|
|
|
966
1222
|
const avisadas = [];
|
|
967
1223
|
/** Las alertas que de verdad salieron en el texto. Ver el encolado del final. */
|
|
968
1224
|
const idsServidos = [];
|
|
1225
|
+
/** Cuántos de los ficheros pendientes pertenecían a algún módulo. Es lo que
|
|
1226
|
+
* separa «no era de nadie» de «era de alguien y aun así no había nada que
|
|
1227
|
+
* decir», que son los dos silencios que la tasa NO puede mezclar. */
|
|
1228
|
+
let conModulo = 0;
|
|
969
1229
|
for (const file of pendientes) {
|
|
970
1230
|
const modulos = modulosDelArchivo(file, signals.rows);
|
|
971
1231
|
if (modulos.length === 0)
|
|
972
1232
|
continue;
|
|
1233
|
+
conModulo += 1;
|
|
973
1234
|
const dependientes = dependentsOf(modulos.map((m) => m.module), depsRows);
|
|
974
1235
|
const impactos = modulos
|
|
975
1236
|
.map((m) => ({
|
|
@@ -1024,19 +1285,31 @@ async function buildImpact(db, payload) {
|
|
|
1024
1285
|
idsServidos.push(...m.alerts.flatMap((a) => a.alertIds));
|
|
1025
1286
|
}
|
|
1026
1287
|
}
|
|
1288
|
+
// Los veredictos del grep se encolan ANTES de los dos `callar` de abajo: que
|
|
1289
|
+
// no haya nada que decir no borra lo que el grep acaba de averiguar. Un append
|
|
1290
|
+
// y nada de red, igual que la entrega — la subida es del `--warm`.
|
|
1291
|
+
if (refutados.length > 0) {
|
|
1292
|
+
anexarALaCola(colaFile, { refutados });
|
|
1293
|
+
}
|
|
1027
1294
|
// Solo se marca como avisado lo que de verdad se dijo: si el archivo no tenía
|
|
1028
1295
|
// nada hoy pero mañana sale una alerta suya, el aviso tiene que poder salir.
|
|
1029
1296
|
if (avisadas.length > 0) {
|
|
1030
1297
|
escribirJson(seenFile, estadoSiguiente(leerJson(seenFile), sessionId, avisadas, ahora));
|
|
1031
1298
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1299
|
+
// Los dos silencios, separados por `conModulo`. Sin esa distinción la tasa
|
|
1300
|
+
// sube sola cada vez que alguien edita un README y no dice nada de la
|
|
1301
|
+
// puntería del hook, que es lo único que se quería medir.
|
|
1302
|
+
if (lineas.length === 0) {
|
|
1303
|
+
return callar(conModulo === 0 ? "fuera-de-modulo" : "sin-lineas");
|
|
1304
|
+
}
|
|
1034
1305
|
// EL TECHO, sobre el total y no por fichero: una edición que toca cuatro
|
|
1035
1306
|
// archivos servía cuatro bloques enteros, y el coste del canal se multiplicaba
|
|
1036
1307
|
// sin que ningún tope lo viera. Ver IMPACT_BUDGET_CHARS.
|
|
1037
1308
|
const texto = recortarAlPresupuesto(lineas);
|
|
1309
|
+
// Había líneas y el presupuesto las dejó en nada: el fichero tenía módulo, así
|
|
1310
|
+
// que esto es silencio con contexto, no ausencia de contexto.
|
|
1038
1311
|
if (!texto)
|
|
1039
|
-
return
|
|
1312
|
+
return callar("sin-lineas");
|
|
1040
1313
|
// La entrega, anotada en disco. `MAX_MODULES` arriba no es cosmético: los
|
|
1041
1314
|
// módulos que caen en el «+N more» NO se sirvieron, así que sus alertas no
|
|
1042
1315
|
// pueden entrar en el registro de lo servido.
|
|
@@ -1044,7 +1317,7 @@ async function buildImpact(db, payload) {
|
|
|
1044
1317
|
// headShaDe es un `git rev-parse` síncrono (~5 ms) y cabe de sobra en el
|
|
1045
1318
|
// presupuesto; el hash de un texto de dos kilobytes no se mide. Lo que NO
|
|
1046
1319
|
// entra aquí es la red: eso es del `--warm`.
|
|
1047
|
-
encolarEntrega(
|
|
1320
|
+
encolarEntrega(colaFile, {
|
|
1048
1321
|
project_id: signals.project_id,
|
|
1049
1322
|
chars_served: texto.length,
|
|
1050
1323
|
...entregaDe({
|
|
@@ -1089,12 +1362,33 @@ export async function printImpact(db) {
|
|
|
1089
1362
|
catch {
|
|
1090
1363
|
return;
|
|
1091
1364
|
}
|
|
1365
|
+
// El motivo del silencio lo anota quien lo conoce, y solo UNA vez: el
|
|
1366
|
+
// token viaja a buildImpact para que un `timeout` de aquí no se sume al
|
|
1367
|
+
// motivo que aquél anotaría al terminar (sigue corriendo un rato después
|
|
1368
|
+
// de perder la carrera). Contar dos veces la misma edición es peor que no
|
|
1369
|
+
// contarla: infla el denominador con algo que nunca pasó.
|
|
1370
|
+
const marca = { contado: false };
|
|
1371
|
+
let vencio = false;
|
|
1372
|
+
let fallo = false;
|
|
1092
1373
|
const additionalContext = await Promise.race([
|
|
1093
|
-
buildImpact(db, payload).catch(() =>
|
|
1094
|
-
|
|
1374
|
+
buildImpact(db, payload, marca).catch(() => {
|
|
1375
|
+
fallo = true;
|
|
1376
|
+
return null;
|
|
1377
|
+
}),
|
|
1378
|
+
vencido.then(() => {
|
|
1379
|
+
vencio = true;
|
|
1380
|
+
return null;
|
|
1381
|
+
}),
|
|
1095
1382
|
]);
|
|
1096
|
-
if (!additionalContext)
|
|
1383
|
+
if (!additionalContext) {
|
|
1384
|
+
// `timeout` y `error` no los ve buildImpact: uno lo decide la carrera y
|
|
1385
|
+
// el otro se lo come el catch. Los demás motivos ya se anotaron dentro.
|
|
1386
|
+
if (!marca.contado && (vencio || fallo)) {
|
|
1387
|
+
marca.contado = true;
|
|
1388
|
+
anotarSilencio(await queuePath(payload.cwd?.trim() || process.cwd()), vencio ? "timeout" : "error");
|
|
1389
|
+
}
|
|
1097
1390
|
return;
|
|
1391
|
+
}
|
|
1098
1392
|
process.stdout.write(JSON.stringify({
|
|
1099
1393
|
hookSpecificOutput: {
|
|
1100
1394
|
hookEventName: "PreToolUse",
|