changebook 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aciertos.js +100 -0
- package/dist/aliasDeModulo.js +203 -0
- package/dist/badge.js +159 -0
- package/dist/context.js +7 -1
- package/dist/guard.js +96 -13
- package/dist/hookMudo.js +172 -0
- package/dist/impact.js +319 -25
- package/dist/import.js +26 -1
- package/dist/index.js +173 -5
- package/dist/pregunta.js +59 -0
- package/dist/reglas.js +299 -0
- package/dist/scan.js +427 -0
- package/dist/supabase.js +84 -1
- package/dist/sync.js +291 -24
- package/dist/tools.js +182 -114
- package/package.json +2 -2
- package/server.json +3 -3
package/dist/aciertos.js
ADDED
|
@@ -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 `[](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
|
-
|
|
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
|