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.
@@ -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(entrega)}\n`);
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
- const [rows, deps, alerts] = await Promise.all([
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
- const toplevel = await execFileAsync("git", ["rev-parse", "--show-toplevel"], {
894
- cwd: dir,
895
- encoding: "utf8",
896
- })
897
- .then(({ stdout }) => stdout.trim())
898
- .catch(() => dir);
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 null;
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 null;
1143
+ return callar("ya-avisado");
913
1144
  const signals = await atlasSignals(db, dir);
914
1145
  if (!signals?.project_id)
915
- return null;
916
- const recidivism = computeRecidivism(signals.alerts);
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
- return avisoRefutado(a, (s) => contarEnRepo(toplevel, s));
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
- if (lineas.length === 0)
1033
- return null;
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 null;
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(await queuePath(dir), {
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(() => null),
1094
- vencido,
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",