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,100 @@
1
+ import * as fs from 'node:fs';
2
+ import { gitPath } from './git.js';
3
+ /** Cuántos días atrás se mira. Más allá, la noticia ya no es noticia. */
4
+ export const VENTANA_DIAS = 14;
5
+ /**
6
+ * La línea, o cadena vacía.
7
+ *
8
+ * UNA SOLA LÍNEA Y UNA SOLA ALERTA. Con dos aciertos a la vez la tentación es
9
+ * listarlos, y una lista de autoelogio en cada commit es ruido con otro
10
+ * nombre. Se dice el más reciente y se callan los demás; ya se dirán cuando
11
+ * toque, o no se dirán, que tampoco pasa nada.
12
+ */
13
+ export function lineaDeAcierto(candidatos, yaDichas, ahora = Date.now()) {
14
+ const frescos = candidatos
15
+ .filter((a) => a.id && !yaDichas.has(a.id))
16
+ .filter((a) => (a.resolution ?? '') === 'fixed')
17
+ .filter((a) => (a.plain ?? '').trim().length > 0)
18
+ .filter((a) => {
19
+ const t = Date.parse(a.resolved_at ?? '');
20
+ return Number.isFinite(t) && ahora - t <= VENTANA_DIAS * 86_400_000;
21
+ })
22
+ .sort((a, b) => Date.parse(b.resolved_at ?? '') - Date.parse(a.resolved_at ?? ''));
23
+ const a = frescos[0];
24
+ if (!a)
25
+ return null;
26
+ const modulo = (a.module ?? '').trim();
27
+ const donde = modulo ? ` sobre ${modulo}` : '';
28
+ // QUIÉN lo juzgó va en la frase, no en una nota al pie. «Lo confirmaste tú» y
29
+ // «lo dio por bueno el agente que lo arregló» no valen lo mismo, y el producto
30
+ // no puede cobrarse el segundo como si fuera el primero.
31
+ const quien = a.resolution_by === 'human'
32
+ ? 'y lo confirmaste tú'
33
+ : a.resolution_by === 'grep'
34
+ ? 'y lo confirmó una comprobación en tu código'
35
+ : 'y lo dio por bueno el agente que lo arregló';
36
+ return {
37
+ id: a.id,
38
+ texto: `\n✓ ChangeBook acertó: el aviso${donde} era real, ${quien}.\n «${recorta(a.plain ?? '')}»\n`,
39
+ };
40
+ }
41
+ /** El texto del aviso, acotado: una línea de commit no es un informe. */
42
+ function recorta(s, max = 110) {
43
+ const limpio = s.trim().replace(/\s+/g, ' ');
44
+ return limpio.length <= max ? limpio : `${limpio.slice(0, max - 1)}…`;
45
+ }
46
+ /** Las que ya se dijeron. Fichero propio, mismo sitio que el resto del estado. */
47
+ async function memoriaPath(dir) {
48
+ return gitPath(dir, 'changebook-aciertos.json').catch(() => null);
49
+ }
50
+ function leerDichas(file) {
51
+ if (!file)
52
+ return new Set();
53
+ try {
54
+ const v = JSON.parse(fs.readFileSync(file, 'utf8'));
55
+ return new Set(Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
56
+ }
57
+ catch {
58
+ return new Set();
59
+ }
60
+ }
61
+ function guardarDichas(file, ids) {
62
+ if (!file)
63
+ return;
64
+ try {
65
+ // Se recorta a las últimas 200: es una memoria para no repetirse, no un
66
+ // registro. Un fichero que solo crece acaba siendo un fichero que nadie
67
+ // borra y que se lee entero en cada commit.
68
+ fs.writeFileSync(file, JSON.stringify([...ids].slice(-200)));
69
+ }
70
+ catch {
71
+ // Sin sitio donde anotar se repetirá una vez. Es el lado seguro.
72
+ }
73
+ }
74
+ /** `CHANGEBOOK_ACIERTOS=off` lo apaga del todo. */
75
+ export function estaSilenciado(env = process.env) {
76
+ return (env.CHANGEBOOK_ACIERTOS ?? '').trim().toLowerCase() === 'off';
77
+ }
78
+ /**
79
+ * La línea para este repo, y la marca para no repetirla.
80
+ *
81
+ * Best-effort de principio a fin: corre dentro del pre-commit y no puede
82
+ * romperlo. Y si no hay nada que celebrar, silencio — que es el estado normal.
83
+ */
84
+ export function acierto(candidatos, dir, env) {
85
+ if (estaSilenciado(env))
86
+ return Promise.resolve('');
87
+ return memoriaPath(dir)
88
+ .catch(() => null)
89
+ .then((file) => {
90
+ const dichas = leerDichas(file);
91
+ const linea = lineaDeAcierto(candidatos, dichas);
92
+ if (!linea)
93
+ return '';
94
+ dichas.add(linea.id);
95
+ guardarDichas(file, dichas);
96
+ return linea.texto;
97
+ })
98
+ .catch(() => '');
99
+ }
100
+ //# sourceMappingURL=aciertos.js.map
@@ -0,0 +1,203 @@
1
+ // ── Alias de módulo, lado CLI ────────────────────────────────────────────────
2
+ //
3
+ // ESPEJO de `fetchModuleAliases` / `canonicalizeModuleRows` en
4
+ // supabase/functions/_shared/analysis.ts. La paridad está testada en
5
+ // test/aliasCliParidad.test.ts, y hace falta: el atlas se alimenta y se lee por
6
+ // TRES superficies —las functions del servidor, el MCP hospedado y este CLI— y
7
+ // una fusión que solo resolviera en dos de ellas haría que el guardián avisara
8
+ // sobre un módulo que el mapa ya no muestra con ese nombre.
9
+ //
10
+ // No se puede importar el original: aquel es código Deno y esto es Node.
11
+ export const SIN_ALIAS = {
12
+ aliases: new Map(),
13
+ hermanas: new Map(),
14
+ };
15
+ /** El MISMO slug que usa el servidor (slugModule) y el mapa de la web. */
16
+ export function slugModule(s) {
17
+ return (s
18
+ .toLowerCase()
19
+ .normalize('NFD')
20
+ .replace(/[\u0300-\u036f]/g, '')
21
+ .replace(/[^a-z0-9]+/g, '-')
22
+ .replace(/^-|-$/g, '') || 'modulo');
23
+ }
24
+ /** Aplana las cadenas (A→B, B→C deja A en C) con tope y detección de ciclo. */
25
+ export function resolveAliasChains(directo) {
26
+ const out = new Map();
27
+ for (const [from, to] of directo) {
28
+ let actual = to;
29
+ const visto = new Set([from]);
30
+ for (let salto = 0; salto < 10; salto++) {
31
+ const siguiente = directo.get(slugModule(actual));
32
+ if (!siguiente || visto.has(slugModule(actual)))
33
+ break;
34
+ visto.add(slugModule(actual));
35
+ actual = siguiente;
36
+ }
37
+ if (slugModule(actual) !== from)
38
+ out.set(from, actual);
39
+ }
40
+ return out;
41
+ }
42
+ /**
43
+ * slug → etiqueta que lo representa. ESPEJO de `etiquetaPorSlug` en
44
+ * supabase/functions/mcp/scope.ts; la paridad la vigila
45
+ * test/paridadCoCambios.test.ts, porque el CLI y el hospedado calculan los
46
+ * mismos co-cambios y una discrepancia aquí los haría diferir en silencio.
47
+ *
48
+ * Existe porque `canonicalModule` NO cubre esta clase: hace
49
+ * `if (aliases.size === 0) return limpia`, así que con la tabla de alias vacía
50
+ * —0 filas en los tres proyectos— «Brief de orientación» y «Brief de
51
+ * orientacion» siguen siendo dos módulos distintos al agrupar.
52
+ *
53
+ * Desempate: la más frecuente; a igualdad, la más reciente. `labels` viene
54
+ * newest-first, así que la primera vista es la más reciente.
55
+ */
56
+ export function etiquetaPorSlug(labels) {
57
+ const cuentas = new Map();
58
+ for (const raw of labels) {
59
+ const label = (raw ?? '').trim();
60
+ if (!label)
61
+ continue;
62
+ const slug = slugModule(label);
63
+ let porEtiqueta = cuentas.get(slug);
64
+ if (!porEtiqueta) {
65
+ porEtiqueta = new Map();
66
+ cuentas.set(slug, porEtiqueta);
67
+ }
68
+ porEtiqueta.set(label, (porEtiqueta.get(label) ?? 0) + 1);
69
+ }
70
+ const out = new Map();
71
+ for (const [slug, porEtiqueta] of cuentas) {
72
+ let mejor = '';
73
+ let mejorN = -1;
74
+ // `>` estricto: al recorrer en orden de aparición, la primera etiqueta (la
75
+ // más reciente) se queda cuando hay empate de frecuencia.
76
+ for (const [label, n] of porEtiqueta) {
77
+ if (n > mejorN) {
78
+ mejor = label;
79
+ mejorN = n;
80
+ }
81
+ }
82
+ out.set(slug, mejor);
83
+ }
84
+ return out;
85
+ }
86
+ export function canonicalModule(label, aliases) {
87
+ const limpia = (label ?? '').trim();
88
+ if (!limpia || aliases.size === 0)
89
+ return limpia;
90
+ return aliases.get(slugModule(limpia)) ?? limpia;
91
+ }
92
+ /**
93
+ * El punto de estrangulamiento: se llama al traer las filas, antes de agregar.
94
+ *
95
+ * ★ `deps` TAMBIÉN (2026-07-31): `change_module.deps` es un array DE NOMBRES DE
96
+ * MÓDULO y `dependentsOf` los casa por texto. Resolver solo la columna dejaba
97
+ * una arista declarada bajo la etiqueta vieja apuntando a un nodo que ya no se
98
+ * llama así, y el dependiente desaparecía del radio de impacto en silencio.
99
+ */
100
+ export function canonicalizeModuleRows(rows, aliases) {
101
+ if (aliases.size === 0)
102
+ return rows;
103
+ return rows.map((r) => {
104
+ const crudo = r;
105
+ const module = crudo.module
106
+ ? canonicalModule(crudo.module, aliases)
107
+ : crudo.module;
108
+ if (!Array.isArray(crudo.deps)) {
109
+ return crudo.module ? { ...r, module } : r;
110
+ }
111
+ // Se deduplica: dos aristas hacia las dos etiquetas de un módulo fusionado
112
+ // son UNA arista, y dejarlas contaría dos veces al mismo dependiente.
113
+ const deps = [
114
+ ...new Set(crudo.deps.map((d) => typeof d === 'string' ? canonicalModule(d, aliases) : d)),
115
+ ];
116
+ return { ...r, module, deps };
117
+ });
118
+ }
119
+ /**
120
+ * Todas las etiquetas CRUDAS que acaban en el mismo canónico, incluida la propia.
121
+ *
122
+ * ESPEJO de `expandModuleNames` en analysis.ts, y existe por lo mismo: el CLI
123
+ * también filtra EN EL SERVIDOR por nombre de módulo (`module=in.(…)` sobre
124
+ * `regression_alerts` y `change_module`). En la base siguen las etiquetas viejas
125
+ * —el alias se resuelve al leer, no se reescribe nada—, así que preguntar por el
126
+ * canónico a secas PIERDE justo las filas que la fusión pretendía recuperar.
127
+ * Se expande ANTES de consultar y se canonicaliza DESPUÉS.
128
+ */
129
+ export function expandModuleNames(names, hermanas) {
130
+ if (hermanas.size === 0)
131
+ return names;
132
+ const out = new Set();
133
+ for (const n of names) {
134
+ out.add(n);
135
+ for (const h of hermanas.get(slugModule(n)) ?? [])
136
+ out.add(h);
137
+ }
138
+ return [...out];
139
+ }
140
+ /** canónico (slug) → etiquetas crudas que resuelven a él. ESPEJO de analysis.ts. */
141
+ export function siblingLabels(filas, aliases) {
142
+ // Se agrupa por el canónico YA RESUELTO, no por el de cada fila: una cadena
143
+ // A→B→C tiene que dejar A, B y C en el mismo grupo, no en dos.
144
+ const grupos = new Map();
145
+ for (const f of filas) {
146
+ const label = (f.alias_label ?? '').trim();
147
+ if (!label)
148
+ continue;
149
+ const canonico = canonicalModule(label, aliases);
150
+ const clave = slugModule(canonico);
151
+ const g = grupos.get(clave) ?? new Set([canonico]);
152
+ g.add(label);
153
+ grupos.set(clave, g);
154
+ }
155
+ // Se indexa por CADA miembro: preguntar por la etiqueta vieja o por la buena
156
+ // tiene que devolver el grupo entero, porque quien consulta no sabe cuál tiene.
157
+ const out = new Map();
158
+ for (const g of grupos.values()) {
159
+ const miembros = [...g];
160
+ for (const m of miembros)
161
+ out.set(slugModule(m), miembros);
162
+ }
163
+ return out;
164
+ }
165
+ /**
166
+ * El id del proyecto que ya lleva dentro el filtro `project_id=eq.…`.
167
+ *
168
+ * Los tres lectores del CLI resuelven el proyecto para poder filtrar y ninguno
169
+ * se queda con el id. Sacarlo de aquí evita una segunda resolución (que en
170
+ * producción tarda ~590 ms, medido) por el gusto de tener la misma cadena.
171
+ */
172
+ export function projectIdOf(projectFilter) {
173
+ return /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
174
+ }
175
+ /**
176
+ * Los alias del proyecto. Best-effort como el resto del contexto del CLI: sin
177
+ * tabla (versión vieja del servidor) o con error, contexto vacío y todo se
178
+ * comporta como antes de que existiera. Un guardián caído por leer alias sería
179
+ * mucho peor que un guardián que no los resuelve.
180
+ *
181
+ * ES LA ÚNICA LECTURA DE `module_alias` EN EL CLI, a propósito: la identidad del
182
+ * módulo ya vivió en tres sitios una vez y divergió sin ruido.
183
+ */
184
+ export async function aliasesFor(db, projectId) {
185
+ if (!projectId)
186
+ return SIN_ALIAS;
187
+ try {
188
+ const rows = await db.rest(`module_alias?select=alias_slug,alias_label,canonical&project_id=eq.${projectId}&limit=500`);
189
+ const directo = new Map();
190
+ for (const r of rows) {
191
+ const from = (r.alias_slug ?? '').trim();
192
+ const to = (r.canonical ?? '').trim();
193
+ if (from && to)
194
+ directo.set(from, to);
195
+ }
196
+ const aliases = resolveAliasChains(directo);
197
+ return { aliases, hermanas: siblingLabels(rows, aliases) };
198
+ }
199
+ catch {
200
+ return SIN_ALIAS;
201
+ }
202
+ }
203
+ //# sourceMappingURL=aliasDeModulo.js.map
package/dist/badge.js ADDED
@@ -0,0 +1,159 @@
1
+ import { projectNameFor } from './git.js';
2
+ import { slugifyProject } from './guard.js';
3
+ import { REPO_JOVEN } from './scan.js';
4
+ /**
5
+ * Del informe a la fila. Pura, para poder probar la regla del repo joven sin
6
+ * red: es la que decide si el badge enseña un porcentaje o se calla.
7
+ */
8
+ export function filaDeBadge(informe, ids, ahora = new Date().toISOString()) {
9
+ return {
10
+ ...ids,
11
+ commits: informe.commits,
12
+ ficheros_vivos: informe.ficherosVivos,
13
+ concentracion_ficheros: informe.concentracion.ficheros,
14
+ concentracion_pct: informe.concentracion.porcentaje,
15
+ // MISMO umbral que usa `scan` para callar el porcentaje en pantalla. Si los
16
+ // dos no coinciden, el badge afirma lo que el informe se negó a afirmar.
17
+ joven: informe.commits < REPO_JOVEN,
18
+ updated_at: ahora,
19
+ };
20
+ }
21
+ /** El markdown que el dueño pega en su README. */
22
+ export function snippetDeBadge(base, token) {
23
+ const img = `${base}/functions/v1/badge?token=${token}`;
24
+ return `[![ChangeBook](${img})](https://changebook.app)`;
25
+ }
26
+ /**
27
+ * Lo que se hace público al encender el badge, dicho ANTES de encenderlo.
28
+ *
29
+ * No es cortesía: la primera versión ataba el badge a `share_token`, o sea que
30
+ * pedir una imagen publicaba el atlas entero —módulos, riesgos y el resumen en
31
+ * llano de cada cambio—. Son dos permisos de tamaños muy distintos y juntarlos
32
+ * hacía que el pequeño costara el precio del grande.
33
+ */
34
+ export const LO_QUE_SE_PUBLICA = [
35
+ 'El badge hace públicos CUATRO NÚMEROS, y nada más:',
36
+ ' · cuántos commits tiene el repo',
37
+ ' · cuántos ficheros vivos',
38
+ ' · cuántos de ellos concentran la mitad de los cambios, y su porcentaje',
39
+ '',
40
+ 'NO se publica: ni código, ni diffs, ni nombres de fichero, ni nombres de',
41
+ 'módulo, ni resúmenes de tus cambios, ni fechas. Es una llave aparte de la de',
42
+ 'compartir el atlas: encender el badge NO publica tu historial.',
43
+ '',
44
+ 'Se apaga cuando quieras con: changebook scan --badge --off',
45
+ ].join('\n');
46
+ /**
47
+ * El proyecto de este repo, con la MISMA cascada que usa `projectFilterFor`:
48
+ * slug primero, luego nombre, y si la cuenta solo tiene uno, ése.
49
+ *
50
+ * Buscar por `name=eq.` a secas —que es lo que hacía la primera versión— falla
51
+ * en cuanto la carpeta no se llama exactamente igual que el proyecto: aquí el
52
+ * directorio es `AppAtlas` y el proyecto `appatlas`, y el comando contestaba
53
+ * «no hay ningún proyecto llamado AppAtlas» teniéndolo delante. Se vio
54
+ * ejecutándolo, no leyéndolo.
55
+ */
56
+ export async function proyectoDe(db, nombre) {
57
+ const campos = 'select=id,user_id,badge_token';
58
+ const slug = slugifyProject(nombre);
59
+ const buscar = (q) => db.rest(`projects?${campos}&${q}&limit=1`);
60
+ if (slug) {
61
+ const porSlug = await buscar(`slug=eq.${encodeURIComponent(slug)}`);
62
+ if (porSlug[0])
63
+ return porSlug[0];
64
+ }
65
+ const porNombre = await buscar(`name=eq.${encodeURIComponent(nombre)}`);
66
+ if (porNombre[0])
67
+ return porNombre[0];
68
+ // Con UN solo proyecto no hay frontera que proteger: no hay entre qué elegir.
69
+ const todos = await db.rest(`projects?${campos}&order=created_at.asc&limit=2`);
70
+ return todos.length === 1 ? todos[0] : null;
71
+ }
72
+ const SIN_PROYECTO = (nombre) => `No hay ningún proyecto llamado "${nombre}" en tu cuenta de ChangeBook.\n` +
73
+ `Analiza al menos un commit primero (changebook analyze) y vuelve a intentarlo.`;
74
+ /**
75
+ * Publica los números y devuelve el snippet, encendiendo la llave si hace falta.
76
+ *
77
+ * LA LLAVE SE ENCIENDE AQUÍ, y es una decisión que cambió. La primera versión
78
+ * se apoyaba en `share_token` y se negaba a crear nada: encender ESE token
79
+ * habría convertido «quiero una imagen» en «publico mi atlas entero», que no es
80
+ * lo mismo ni de lejos. Con llave propia el cálculo es otro: lo que se expone
81
+ * son exactamente los cuatro números que el usuario va a ver dibujados, y ha
82
+ * escrito el comando que los pide. Encenderla en silencio seguiría estando mal,
83
+ * así que el comando imprime `LO_QUE_SE_PUBLICA` antes.
84
+ */
85
+ export async function publicarBadge(db, dir, informe) {
86
+ const nombre = projectNameFor(dir);
87
+ const proyecto = await proyectoDe(db, nombre);
88
+ if (!proyecto)
89
+ return { ok: false, motivo: SIN_PROYECTO(nombre) };
90
+ // LOS NÚMEROS PRIMERO, LA LLAVE DESPUÉS. El orden no es indiferente: encender
91
+ // la llave abre una URL pública, y hacerlo antes de saber si hay algo que
92
+ // servir deja el badge encendido aunque la publicación falle. Pasó de verdad
93
+ // —el primer intento reventó con un 42501 DESPUÉS de haber encendido la
94
+ // llave—, y el usuario vio un error creyendo que no había pasado nada
95
+ // mientras la URL ya existía. Un permiso se abre cuando ya hay algo detrás.
96
+ //
97
+ // Por RPC y no por un upsert a la tabla. Un upsert escribe TODAS las columnas
98
+ // enviadas, incluidas `project_id` y `user_id`, que están fuera del grant por
99
+ // columna a propósito: son la frontera entre cuentas. Medido ejecutándolo —
100
+ // 42501— y la salida fácil (añadirlas al grant) es el agujero que
101
+ // 20260710120000 cerró en `regression_alerts`. La función valida la propiedad
102
+ // y el cliente se queda sin escritura directa.
103
+ const fila = filaDeBadge(informe, {
104
+ project_id: proyecto.id,
105
+ user_id: proyecto.user_id,
106
+ });
107
+ await db.callRpc('badge_publicar', {
108
+ p_project_id: fila.project_id,
109
+ p_commits: fila.commits,
110
+ p_ficheros_vivos: fila.ficheros_vivos,
111
+ p_concentracion_ficheros: fila.concentracion_ficheros,
112
+ p_concentracion_pct: fila.concentracion_pct,
113
+ p_joven: fila.joven,
114
+ });
115
+ // El token lo genera la BASE, no el cliente: uno elegido por quien lo usa se
116
+ // puede adivinar o reutilizar entre proyectos. `coalesce` dentro de la función
117
+ // hace que reencenderlo devuelva el mismo, así que republicar no invalida el
118
+ // badge que ya está pegado en un README.
119
+ const token = proyecto.badge_token ??
120
+ (await db.callRpc('badge_encender', {
121
+ p_project_id: proyecto.id,
122
+ }));
123
+ // `callRpc` devuelve null ante un cuerpo vacío (204) en vez de lanzar, que es
124
+ // lo correcto para las RPC `void` — pero aquí un null significaría imprimir
125
+ // una URL con la palabra «null» dentro y llamarlo éxito. Se para en seco: un
126
+ // snippet roto pegado en un README es peor que un error en la terminal.
127
+ if (!token) {
128
+ return {
129
+ ok: false,
130
+ motivo: 'La base no devolvió la llave del badge. No se ha impreso ningún\n' +
131
+ 'snippet: pegar una URL incompleta en tu README sería peor. Vuelve a\n' +
132
+ 'intentarlo, y si sigue, es un fallo del servidor y no tuyo.',
133
+ };
134
+ }
135
+ return {
136
+ ok: true,
137
+ snippet: snippetDeBadge(db.baseUrl, token),
138
+ nuevo: !proyecto.badge_token,
139
+ };
140
+ }
141
+ /**
142
+ * Apaga el badge: borra la llave y los números.
143
+ *
144
+ * Los números se van con la llave a propósito. Si el dueño retira el badge, los
145
+ * datos que solo existían para dibujarlo no se quedan ahí esperando a que
146
+ * alguien los vuelva a exponer.
147
+ */
148
+ export async function apagarBadge(db, dir) {
149
+ const nombre = projectNameFor(dir);
150
+ const proyecto = await proyectoDe(db, nombre);
151
+ if (!proyecto)
152
+ return { ok: false, motivo: SIN_PROYECTO(nombre) };
153
+ if (!proyecto.badge_token) {
154
+ return { ok: false, motivo: `"${nombre}" no tiene ningún badge encendido.` };
155
+ }
156
+ await db.callRpc('badge_apagar', { p_project_id: proyecto.id });
157
+ return { ok: true, snippet: '', apagado: true };
158
+ }
159
+ //# sourceMappingURL=badge.js.map
package/dist/context.js CHANGED
@@ -24,6 +24,7 @@
24
24
  import * as fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { feedWarningFor } from "./feed.js";
27
+ import { avisoDeHookMudoPara } from "./hookMudo.js";
27
28
  import { fetchBriefSection } from "./sync.js";
28
29
  import { commitAliasesShort, derivaContraHead } from "./tools.js";
29
30
  /** Hard ceiling on the critical path: past this, emit nothing and move on. */
@@ -204,6 +205,11 @@ async function buildSessionContext(db, dir) {
204
205
  // Local and offline: one small file plus a git rev-parse. It cannot fail
205
206
  // because of the network or the session, which is the point.
206
207
  const pulse = await feedWarningFor(dir, { audience: "agent" }).catch(() => "");
208
+ // El segundo silencio (C6): el aviso previo a editar puede llevar semanas sin
209
+ // darse y el agente no tiene forma de notarlo — para él, «no me dijeron nada»
210
+ // y «no había nada que decir» son la misma frase. Por eso se le dice a él
211
+ // también y no solo al humano en el pre-commit.
212
+ const mudo = await avisoDeHookMudoPara(dir, { audience: "agent" }).catch(() => "");
207
213
  let brief = null;
208
214
  try {
209
215
  brief = await buildPayload(db, dir);
@@ -211,7 +217,7 @@ async function buildSessionContext(db, dir) {
211
217
  catch {
212
218
  brief = null;
213
219
  }
214
- const parts = [pulse, brief].filter(Boolean);
220
+ const parts = [pulse, mudo, brief].filter(Boolean);
215
221
  return parts.length > 0 ? parts.join("\n\n") : null;
216
222
  }
217
223
  export async function printContext(db, dir) {
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