changebook 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/guard.js CHANGED
@@ -17,7 +17,11 @@ import { execFileSync } from "node:child_process";
17
17
  import { createHash } from "node:crypto";
18
18
  import { execFileAsync, gitPath, projectNameFor } from "./git.js";
19
19
  import { feedWarningFor } from "./feed.js";
20
+ import { VENTANA_DIAS, acierto, estaSilenciado, } from "./aciertos.js";
21
+ import { avisoDeHookMudoPara } from "./hookMudo.js";
22
+ import { correrReglas, informeDeReglas, repoEnDisco, } from "./reglas.js";
20
23
  import { avisoDeRamaPara } from "./rama.js";
24
+ import { aliasesFor, canonicalizeModuleRows, expandModuleNames, } from "./aliasDeModulo.js";
21
25
  /** Exit code that asks the pre-commit hook to abort the commit. */
22
26
  export const EXIT_BLOCK = 3;
23
27
  // A commit should never feel slow because of us: whatever the network hasn't
@@ -462,6 +466,38 @@ function grepDelRepo(dir, simbolo, modo) {
462
466
  * siempre.
463
467
  */
464
468
  const GUARD_CACHE_V = 1;
469
+ /**
470
+ * Los hallazgos de B9 que tocan lo que se va a commitear.
471
+ *
472
+ * Las reglas necesitan ver el repo ENTERO —para saber qué función SQL se
473
+ * redefinió, hay que leer todas las migraciones— pero solo se HABLA de los
474
+ * ficheros del commit. Sin ese recorte, un repo con seis hallazgos viejos
475
+ * saluda con seis avisos en cada commit hasta que alguien desinstala el hook.
476
+ */
477
+ /**
478
+ * B6 · la línea de reconocimiento, si hay alguna que dar.
479
+ *
480
+ * Consulta propia y acotada: las alertas CERRADAS de los últimos días, que la
481
+ * consulta grande no trae (pide `resolved_at=is.null` a propósito). Trae pocas
482
+ * filas y va con `.catch` por fuera — si falla, no se dice nada y ya está.
483
+ */
484
+ export async function aciertoReciente(db, dir, projectId) {
485
+ if (estaSilenciado())
486
+ return "";
487
+ const desde = new Date(Date.now() - VENTANA_DIAS * 86_400_000).toISOString();
488
+ const filas = await db.rest(`regression_alerts?select=id,module,plain,resolution,resolution_by,resolved_at` +
489
+ `&project_id=eq.${projectId}&resolution=eq.fixed&resolved_at=gte.${desde}` +
490
+ `&order=resolved_at.desc&limit=5`);
491
+ return acierto(filas, dir);
492
+ }
493
+ export async function reglasSobreLoStaged(dir) {
494
+ const staged = new Set(await stagedFiles(dir));
495
+ if (staged.size === 0)
496
+ return [];
497
+ const { stdout } = await execFileAsync("git", ["ls-files"], { cwd: dir });
498
+ const todos = stdout.split("\n").filter(Boolean);
499
+ return correrReglas(repoEnDisco(dir, todos)).filter((h) => staged.has(h.fichero));
500
+ }
465
501
  export async function stagedFiles(dir) {
466
502
  // -z: NUL-separated, and crucially git does NOT octal-quote non-ASCII paths
467
503
  // (default quotepath would emit "m\303\263dulo.ts", which never matches the
@@ -519,23 +555,37 @@ async function fetchSignals(db, dir, env) {
519
555
  let filesByModule = new Map();
520
556
  const projectId = projects[0]?.id ?? null;
521
557
  if (projectId) {
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}`);
558
+ // Los alias van en PARALELO con las alertas, no después: este camino corre
559
+ // en cada pre-commit y una ronda de red más se paga en cada commit del día.
560
+ const [alertasCrudas, { aliases, hermanas }] = await Promise.all([
561
+ db.rest(
562
+ // `evidence_scope` NO es opcional aquí, por mucho que el tipo lo sea: sin
563
+ // él, `alcanceDelRepo` devuelve "sin_declarar" para TODA alerta y la
564
+ // refutación de `avisoRefutado` no descarta nada nunca. El campo se
565
+ // escribe en la base desde el 2026-07-26 y ningún cliente lo pedía; el
566
+ // comentario de `avisoRefutado` decía que esa rama "disparó 0 veces en
567
+ // producción", y disparó cero porque el dato no llegaba, no porque el gate
568
+ // fuera barato. Lo vigila `test/alcanceLlegaAlCliente.test.ts`, que lee
569
+ // ESTA línea: un test de comportamiento no lo caza, porque los stubs
570
+ // inyectan el campo a mano.
571
+ `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}`),
572
+ aliasesFor(db, projectId),
573
+ ]);
574
+ alerts = canonicalizeModuleRows(alertasCrudas, aliases);
533
575
  const modules = [
534
576
  ...new Set(alerts.map((a) => (a.module ?? "").trim()).filter(Boolean)),
535
577
  ];
536
578
  if (modules.length > 0) {
537
- const rows = await db.rest(`change_module?select=module,files,created_at&project_id=eq.${projectId}&module=in.(${encodeURIComponent(pgInList(modules))})&order=created_at.desc&limit=200`);
538
- filesByModule = moduleFilesUnion(rows);
579
+ // EXPANDIR ANTES DE CONSULTAR. El filtro va al servidor por nombre y en la
580
+ // base siguen las etiquetas viejas, así que preguntar solo por el canónico
581
+ // devolvería un trozo de los ficheros del módulo — y el guardián se
582
+ // callaría ante una alerta abierta por un fichero que sí es suyo, que es
583
+ // exactamente el fallo que `moduleFilesUnion` documenta del 2026-07-18.
584
+ const rows = await db.rest(`change_module?select=module,files,created_at&project_id=eq.${projectId}&module=in.(${encodeURIComponent(pgInList(expandModuleNames(modules, hermanas)))})&order=created_at.desc&limit=200`);
585
+ // Y canonicalizar DESPUÉS: la unión tiene que agrupar los ficheros de las
586
+ // dos etiquetas bajo una sola clave, o el aviso buscaría por un nombre que
587
+ // no está en el mapa.
588
+ filesByModule = moduleFilesUnion(canonicalizeModuleRows(rows, aliases));
539
589
  }
540
590
  }
541
591
  if (cacheFile) {
@@ -687,6 +737,27 @@ export async function runGuard(db, dir, env = process.env) {
687
737
  const pulse = await feedWarningFor(dir);
688
738
  if (pulse)
689
739
  console.error(pulse);
740
+ // Y el pulso del OTRO hook (C6). Son dos silencios distintos y hasta hoy solo
741
+ // se vigilaba uno: `feedWarningFor` dice si el atlas deja de alimentarse;
742
+ // esto dice si el aviso PREVIO A EDITAR dejó de darse. Un hook de retención
743
+ // pasiva desinstalado se ve igual que un repo tranquilo, así que sin esto la
744
+ // única señal de que se rompió es que el usuario deje de usar el producto.
745
+ // Offline y de un `stat`, como el de arriba: no puede frenar un commit.
746
+ const mudo = await avisoDeHookMudoPara(dir);
747
+ if (mudo)
748
+ console.error(mudo);
749
+ // B9 · los patrones que ya rompieron aquí. Offline y sin cuenta, como los dos
750
+ // de arriba: son hechos del repo, no del atlas.
751
+ //
752
+ // ACOTADO A LO QUE SE VA A COMMITEAR, y esa decisión es lo que lo hace
753
+ // usable: medidas sobre este repo, las reglas encuentran seis cosas ciertas
754
+ // pero VIEJAS. Avisar de las seis en cada commit sería ruido desde el primer
755
+ // día, y un pre-commit ruidoso se desinstala. Se habla solo de los ficheros
756
+ // que el commit toca; el resto sale en el informe, no aquí.
757
+ const hallazgos = await reglasSobreLoStaged(dir).catch(() => []);
758
+ const informe = informeDeReglas(hallazgos);
759
+ if (informe)
760
+ console.error(informe);
690
761
  // ANTES del corte por credenciales a proposito: que tu rama vaya a revertir el
691
762
  // trabajo de otro es un hecho de git, no del atlas. Quien no tenga sesion —o
692
763
  // no tenga cuenta— tambien merece enterarse. Ver rama.ts para el porque de la
@@ -724,6 +795,18 @@ export async function runGuard(db, dir, env = process.env) {
724
795
  await logRun(dir, `timeout after ${GUARD_TIMEOUT_MS}ms — passing`);
725
796
  return 0;
726
797
  }
798
+ // B6 · cuando el aviso acierta, se dice. Va DESPUÉS de tener señales —así
799
+ // reusa el project_id que ya se resolvió— y es best-effort entero: una línea
800
+ // de reconocimiento no puede costar un commit. Se dice una vez por alerta.
801
+ //
802
+ // Es lo único que este producto dice cuando algo va BIEN. Sin ello solo habla
803
+ // de riesgos, y un producto que solo trae malas noticias se lee como una
804
+ // molestia hasta que se desinstala.
805
+ if (signals.projectId) {
806
+ const linea = await aciertoReciente(db, dir, signals.projectId).catch(() => "");
807
+ if (linea)
808
+ console.error(linea);
809
+ }
727
810
  // Presupuesto de greps para decidir A QUIEN se avisa. `contarEnRepo` y
728
811
  // `ficherosEnRepo` son execFileSync, o sea que BLOQUEAN el bucle de eventos y
729
812
  // el techo de GUARD_TIMEOUT_MS no puede desalojarlos — un Promise.race no gana
@@ -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