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/aciertos.js +100 -0
- package/dist/aliasDeModulo.js +203 -0
- package/dist/analyze.js +40 -2
- package/dist/badge.js +159 -0
- package/dist/context.js +7 -1
- package/dist/git.js +42 -2
- 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 +215 -29
- package/dist/tools.js +182 -44
- package/package.json +2 -2
- package/server.json +3 -3
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
|
-
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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
|
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
|