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/scan.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES } from './git.js';
|
|
3
|
+
// ── `changebook scan` · ver el valor antes de tener cuenta (B2) ──────────────
|
|
4
|
+
//
|
|
5
|
+
// LECTURA PURA. No escribe nada, no pide cuenta, no toca la red, no llama a
|
|
6
|
+
// ningún modelo. Todo lo que dice sale de `git log` del repo que tienes delante.
|
|
7
|
+
// Es lo que permite que sea gratis e ilimitado, y lo que permite correrlo sobre
|
|
8
|
+
// el repo de otro sin pedirle permiso a nadie.
|
|
9
|
+
//
|
|
10
|
+
// ── LO QUE ESTE FICHERO NO MIDE, Y POR QUÉ ──────────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// La ficha de B2 pedía como titular «los 5 ficheros que más han participado en
|
|
13
|
+
// revertidos y hotfixes». Medido sobre este mismo repo antes de escribir una
|
|
14
|
+
// línea: **492 commits y UN revert**. Y de los dos que casan por mensaje, uno es
|
|
15
|
+
// un falso positivo — `932f395` («Avisar cuando tu rama va a revertir el trabajo
|
|
16
|
+
// de otro») es una funcionalidad SOBRE reverts, no un revert.
|
|
17
|
+
//
|
|
18
|
+
// No es que el repo sea pequeño (la ficha ya preveía ese caso): es que **no
|
|
19
|
+
// emite esa señal**. Hace squash-merge desde ramas y escribe los arreglos hacia
|
|
20
|
+
// delante, con mensajes narrativos («El corte de PostgREST deja de mentir»).
|
|
21
|
+
// Muchos repos sí la emiten —`fix:`, `hotfix`, `Revert "…"` es convención común—
|
|
22
|
+
// así que el detector se queda, pero **el informe no la pone de titular cuando
|
|
23
|
+
// no está**: dice cuántas encontró, incluido cero. Un informe que promete un
|
|
24
|
+
// ranking y enseña una lista vacía es peor que uno que dice «aquí no hay».
|
|
25
|
+
//
|
|
26
|
+
// LA TENTACIÓN QUE SE DESCARTÓ. El sustituto obvio es «fichero re-tocado en
|
|
27
|
+
// menos de 24 h» — y este repo tiene 229 así, el primero con 76. Es abundante y
|
|
28
|
+
// es inútil: en un repo con 492 commits en 27 días eso mide ACTIVIDAD, no daño.
|
|
29
|
+
// El primero de la lista es `i18n.ts`, el fichero de traducciones, que se toca
|
|
30
|
+
// siempre y no es frágil. Es el mismo error que el plan ya se reprochó en su
|
|
31
|
+
// línea 928 —«Rotación ≠ daño, y ordené el plan confundiéndolas»— y el mismo que
|
|
32
|
+
// infla el contador de reincidencia. Se calcula y se enseña, pero **etiquetado
|
|
33
|
+
// como lo que es**, nunca como fragilidad.
|
|
34
|
+
//
|
|
35
|
+
// Lo que sí se afirma sin asterisco es el ACOPLAMIENTO: qué ficheros cambian
|
|
36
|
+
// juntos y cuánto arrastra cada uno. Eso no depende de convenciones de mensaje,
|
|
37
|
+
// sale entero de qué ficheros aparecen en el mismo commit, y es exactamente la
|
|
38
|
+
// pregunta que el producto contesta.
|
|
39
|
+
/** Techo de commits leídos. Por encima el informe no mejora y el tiempo sí sube. */
|
|
40
|
+
export const MAX_COMMITS = 1500;
|
|
41
|
+
/**
|
|
42
|
+
* Un commit que toca 300 ficheros (un `npm install`, un formateo masivo) genera
|
|
43
|
+
* 44.850 parejas él solo y ahoga la señal real. Por encima de este techo el
|
|
44
|
+
* commit cuenta para frecuencia pero NO para acoplamiento: no dice nada sobre
|
|
45
|
+
* qué va con qué.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_FICHEROS_POR_COMMIT_PARA_PAREJAS = 25;
|
|
48
|
+
/** Ventana del retoque rápido. */
|
|
49
|
+
const VENTANA_RETOQUE_MS = 24 * 60 * 60 * 1000;
|
|
50
|
+
/**
|
|
51
|
+
* Por debajo de esto el informe enseña hechos y se calla los porcentajes.
|
|
52
|
+
* No es un umbral de «sirvo / no sirvo»: es dónde deja de haber muestra para
|
|
53
|
+
* que un porcentaje signifique algo.
|
|
54
|
+
*/
|
|
55
|
+
export const REPO_JOVEN = 50;
|
|
56
|
+
// Ruido que no es código de nadie: no debe salir en ningún ranking.
|
|
57
|
+
//
|
|
58
|
+
// OJO CON LA MAGIA `:(glob)`, QUE NO ES DECORACIÓN. Sin ella, un patrón que
|
|
59
|
+
// empieza por doble-asterisco-barra exige AL MENOS un directorio, así que
|
|
60
|
+
// excluye `sub/package-lock.json` y DEJA PASAR el de la raíz. Es exactamente lo
|
|
61
|
+
// que hacía la primera versión de esto: medido sobre este repo,
|
|
62
|
+
// `package-lock.json` y `deno.lock` estaban en los datos mientras el comentario
|
|
63
|
+
// de aquí arriba decía que no. Un filtro que no filtra y no lo dice.
|
|
64
|
+
//
|
|
65
|
+
// Las tres formas, comprobadas contra git y no deducidas:
|
|
66
|
+
// :(exclude) + doble-asterisco/x → excluye el anidado, deja el de la raíz
|
|
67
|
+
// :(exclude)x → excluye el de la raíz, deja el anidado
|
|
68
|
+
// :(glob,exclude) + doble-asterisco/x → excluye los dos ← esta
|
|
69
|
+
//
|
|
70
|
+
// `FICHEROS_GENERADOS` (de git.ts) resuelve lo mismo listando las dos formas.
|
|
71
|
+
// Se importa tal cual: funciona, y no es de este fichero cambiarlo.
|
|
72
|
+
//
|
|
73
|
+
// (Y este bloque va con `//` y no con `/* */` porque un patrón así dentro de un
|
|
74
|
+
// comentario de bloque lo CIERRA. Me pasó al escribirlo.)
|
|
75
|
+
const RUIDO = [
|
|
76
|
+
':(glob,exclude)**/package-lock.json',
|
|
77
|
+
':(glob,exclude)**/*.lock',
|
|
78
|
+
':(glob,exclude)**/dist/**',
|
|
79
|
+
':(glob,exclude)**/out/**',
|
|
80
|
+
...FICHEROS_GENERADOS,
|
|
81
|
+
];
|
|
82
|
+
/** Mensajes que en la convención de la industria significan «esto iba mal». */
|
|
83
|
+
const PATRON_REVERT = /^revert[:\s"]|^revierte\b|\bhotfix\b/i;
|
|
84
|
+
/**
|
|
85
|
+
* Lee el historial UNA vez. `-z` y `--name-only` con separador nulo porque un
|
|
86
|
+
* nombre de fichero puede llevar espacios, y `--no-merges` porque un merge
|
|
87
|
+
* repite ficheros que ya contaron en sus commits.
|
|
88
|
+
*/
|
|
89
|
+
export async function leerHistorial(dir, max = MAX_COMMITS) {
|
|
90
|
+
const { stdout } = await execFileAsync('git', [
|
|
91
|
+
'log',
|
|
92
|
+
`-${max}`,
|
|
93
|
+
'--no-merges',
|
|
94
|
+
'--name-only',
|
|
95
|
+
'--date=unix',
|
|
96
|
+
'--format=%x00C%x00%H%x00%ad%x00%s',
|
|
97
|
+
'--',
|
|
98
|
+
'.',
|
|
99
|
+
...RUIDO,
|
|
100
|
+
], { cwd: dir, maxBuffer: GIT_MAX_BUFFER_BYTES });
|
|
101
|
+
const commits = [];
|
|
102
|
+
let actual = null;
|
|
103
|
+
const trozos = stdout.split('\0');
|
|
104
|
+
for (let i = 0; i < trozos.length; i++) {
|
|
105
|
+
if (trozos[i] === 'C') {
|
|
106
|
+
if (actual)
|
|
107
|
+
commits.push(actual);
|
|
108
|
+
actual = {
|
|
109
|
+
hash: trozos[i + 1] ?? '',
|
|
110
|
+
fecha: Number(trozos[i + 2] ?? 0) * 1000,
|
|
111
|
+
asunto: (trozos[i + 3] ?? '').split('\n')[0] ?? '',
|
|
112
|
+
ficheros: [],
|
|
113
|
+
};
|
|
114
|
+
// El asunto viene pegado al primer fichero por el salto de línea.
|
|
115
|
+
const resto = (trozos[i + 3] ?? '').split('\n').slice(1).filter(Boolean);
|
|
116
|
+
actual.ficheros.push(...resto);
|
|
117
|
+
i += 3;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const lineas = trozos[i].split('\n').filter(Boolean);
|
|
121
|
+
if (actual)
|
|
122
|
+
actual.ficheros.push(...lineas);
|
|
123
|
+
}
|
|
124
|
+
if (actual)
|
|
125
|
+
commits.push(actual);
|
|
126
|
+
return commits;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Cuántos commits tiene el repo, sin filtrar nada. Es el número que necesita la
|
|
130
|
+
* estimación de coste de `init`, y NO es el de `leerHistorial`: ese excluye
|
|
131
|
+
* ruido y topa en MAX_COMMITS, así que usarlo daría una cifra más baja que la
|
|
132
|
+
* que se va a gastar. Devuelve 0 si no hay repo o no hay commits.
|
|
133
|
+
*/
|
|
134
|
+
export async function contarCommits(dir) {
|
|
135
|
+
try {
|
|
136
|
+
const { stdout } = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], {
|
|
137
|
+
cwd: dir,
|
|
138
|
+
});
|
|
139
|
+
const n = Number(stdout.trim());
|
|
140
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Todo el cálculo, sin tocar disco ni red: entra historial, sale informe. */
|
|
147
|
+
export function analizar(proyecto, commits) {
|
|
148
|
+
const cambios = new Map();
|
|
149
|
+
const juntas = new Map();
|
|
150
|
+
const vecinos = new Map();
|
|
151
|
+
const ultimoToque = new Map();
|
|
152
|
+
const retoque = new Map();
|
|
153
|
+
const reverts = [];
|
|
154
|
+
for (const c of commits) {
|
|
155
|
+
if (PATRON_REVERT.test(c.asunto)) {
|
|
156
|
+
reverts.push({ hash: c.hash.slice(0, 7), asunto: c.asunto });
|
|
157
|
+
}
|
|
158
|
+
const unicos = [...new Set(c.ficheros)];
|
|
159
|
+
for (const f of unicos) {
|
|
160
|
+
cambios.set(f, (cambios.get(f) ?? 0) + 1);
|
|
161
|
+
const previo = ultimoToque.get(f);
|
|
162
|
+
// El log viene de nuevo a viejo: el «previo» que ya vimos es POSTERIOR.
|
|
163
|
+
if (previo !== undefined && previo - c.fecha < VENTANA_RETOQUE_MS) {
|
|
164
|
+
retoque.set(f, (retoque.get(f) ?? 0) + 1);
|
|
165
|
+
}
|
|
166
|
+
ultimoToque.set(f, c.fecha);
|
|
167
|
+
}
|
|
168
|
+
if (unicos.length > MAX_FICHEROS_POR_COMMIT_PARA_PAREJAS)
|
|
169
|
+
continue;
|
|
170
|
+
for (let i = 0; i < unicos.length; i++) {
|
|
171
|
+
for (let j = i + 1; j < unicos.length; j++) {
|
|
172
|
+
const [a, b] = unicos[i] < unicos[j] ? [unicos[i], unicos[j]] : [unicos[j], unicos[i]];
|
|
173
|
+
juntas.set(`${a}\0${b}`, (juntas.get(`${a}\0${b}`) ?? 0) + 1);
|
|
174
|
+
if (!vecinos.has(a))
|
|
175
|
+
vecinos.set(a, new Set());
|
|
176
|
+
if (!vecinos.has(b))
|
|
177
|
+
vecinos.set(b, new Set());
|
|
178
|
+
vecinos.get(a).add(b);
|
|
179
|
+
vecinos.get(b).add(a);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const porCambios = [...cambios].sort((x, y) => y[1] - x[1]);
|
|
184
|
+
const totalCambios = porCambios.reduce((s, [, n]) => s + n, 0);
|
|
185
|
+
// Concentración: cuántos ficheros acumulan la mitad de todos los cambios.
|
|
186
|
+
let acumulado = 0;
|
|
187
|
+
let ficherosMitad = 0;
|
|
188
|
+
for (const [, n] of porCambios) {
|
|
189
|
+
acumulado += n;
|
|
190
|
+
ficherosMitad++;
|
|
191
|
+
if (acumulado * 2 >= totalCambios)
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
const parejas = [...juntas]
|
|
195
|
+
.map(([k, n]) => {
|
|
196
|
+
const [a, b] = k.split('\0');
|
|
197
|
+
const menor = Math.min(cambios.get(a) ?? 1, cambios.get(b) ?? 1);
|
|
198
|
+
return { a, b, juntas: n, tasa: menor ? n / menor : 0 };
|
|
199
|
+
})
|
|
200
|
+
// Dos ficheros que coincidieron una vez no son una pareja, son una casualidad.
|
|
201
|
+
.filter((p) => p.juntas >= 3)
|
|
202
|
+
.sort((x, y) => y.tasa - x.tasa || y.juntas - x.juntas);
|
|
203
|
+
return {
|
|
204
|
+
proyecto,
|
|
205
|
+
commits: commits.length,
|
|
206
|
+
desde: commits.length ? new Date(commits[commits.length - 1].fecha).toISOString().slice(0, 10) : null,
|
|
207
|
+
hasta: commits.length ? new Date(commits[0].fecha).toISOString().slice(0, 10) : null,
|
|
208
|
+
ficherosVivos: cambios.size,
|
|
209
|
+
concentracion: {
|
|
210
|
+
ficheros: ficherosMitad,
|
|
211
|
+
porcentaje: cambios.size ? Math.round((ficherosMitad / cambios.size) * 100) : 0,
|
|
212
|
+
},
|
|
213
|
+
masCambiados: porCambios.slice(0, 5).map(([fichero, n]) => ({
|
|
214
|
+
fichero,
|
|
215
|
+
cambios: n,
|
|
216
|
+
arrastra: vecinos.get(fichero)?.size ?? 0,
|
|
217
|
+
})),
|
|
218
|
+
parejas: parejas.slice(0, 5),
|
|
219
|
+
retoqueRapido: [...retoque]
|
|
220
|
+
.sort((x, y) => y[1] - x[1])
|
|
221
|
+
.slice(0, 5)
|
|
222
|
+
.map(([fichero, veces]) => ({ fichero, veces })),
|
|
223
|
+
reverts,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const corto = (f, ancho = 46) => f.length <= ancho ? f : `…${f.slice(-(ancho - 1))}`;
|
|
227
|
+
export function informeDeScan(i) {
|
|
228
|
+
const l = [`ChangeBook · informe de acoplamiento de ${i.proyecto}`, ''];
|
|
229
|
+
if (i.commits === 0) {
|
|
230
|
+
l.push(' Este repositorio todavía no tiene historial que leer.');
|
|
231
|
+
return l.join('\n');
|
|
232
|
+
}
|
|
233
|
+
l.push(` ${i.commits} commits · ${i.ficherosVivos} ficheros · ${i.desde} → ${i.hasta}`, '');
|
|
234
|
+
// ── Repo joven ────────────────────────────────────────────────────────────
|
|
235
|
+
//
|
|
236
|
+
// La ficha de B2 lo pide explícito, y con una instrucción que es la parte
|
|
237
|
+
// importante: «la respuesta correcta no es "aún no hay datos", es "esto es lo
|
|
238
|
+
// que ChangeBook está vigilando ya"». Un producto que se presenta diciendo que
|
|
239
|
+
// no puede decir nada se cierra y no se vuelve a abrir.
|
|
240
|
+
//
|
|
241
|
+
// Lo que cambia por debajo NO es el tono, es qué se afirma. El porcentaje de
|
|
242
|
+
// concentración sobre 12 commits es ruido con pinta de estadística — el mismo
|
|
243
|
+
// pecado que el contador de reincidencia, un tamaño de muestra más abajo. Así
|
|
244
|
+
// que con poco historial se enseñan los HECHOS (qué ficheros, qué parejas) y
|
|
245
|
+
// se calla el PORCENTAJE, en vez de adornarlo con un asterisco que nadie lee.
|
|
246
|
+
if (i.commits < REPO_JOVEN) {
|
|
247
|
+
l.push(` Historial corto todavía (${i.commits} commits), así que aquí no hay`, ` porcentajes: con esta muestra serían ruido con pinta de dato.`, '', ' Esto es lo que ChangeBook ya está viendo, y lo que va a vigilar según crezca:');
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
l.push(` El ${i.concentracion.porcentaje}% de tus ficheros concentra la mitad de todos los cambios ` +
|
|
251
|
+
`(${i.concentracion.ficheros} de ${i.ficherosVivos}).`, '', ' Los ficheros que más arrastran cuando los tocas:');
|
|
252
|
+
}
|
|
253
|
+
for (const [n, f] of i.masCambiados.entries()) {
|
|
254
|
+
l.push(` ${n + 1}. ${corto(f.fichero).padEnd(47)} ${String(f.cambios).padStart(3)} cambios · arrastra ${f.arrastra}`);
|
|
255
|
+
}
|
|
256
|
+
if (i.parejas.length) {
|
|
257
|
+
l.push('', ' Cambian juntos casi siempre — si tocas uno, mira el otro:');
|
|
258
|
+
for (const p of i.parejas) {
|
|
259
|
+
l.push(` ${Math.round(p.tasa * 100)}% ${corto(p.a, 34)} ↔ ${corto(p.b, 34)}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Se dice SIEMPRE, incluido el cero. Un ranking vacío se lee como «no medí»;
|
|
263
|
+
// un cero explícito se lee como «medí y no hay», que es información.
|
|
264
|
+
l.push('', i.reverts.length
|
|
265
|
+
? ` ${i.reverts.length} revertidos o hotfix por patrón de mensaje:`
|
|
266
|
+
: i.commits < REPO_JOVEN
|
|
267
|
+
? // Con 12 commits, «este repo arregla hacia delante» sería inventarse
|
|
268
|
+
// un rasgo de cultura a partir de nada. Cero es cero, y ya está.
|
|
269
|
+
' Revertidos o hotfix por patrón de mensaje: ninguno todavía.'
|
|
270
|
+
: ' Revertidos o hotfix por patrón de mensaje: ninguno. Este repo arregla ' +
|
|
271
|
+
'hacia delante,\n así que esa señal aquí no dice nada — el acoplamiento de arriba sí.');
|
|
272
|
+
for (const r of i.reverts.slice(0, 3))
|
|
273
|
+
l.push(` ${r.hash} ${r.asunto}`);
|
|
274
|
+
if (i.retoqueRapido.length) {
|
|
275
|
+
l.push('', ' Re-tocados en menos de 24 h (esto es ACTIVIDAD, no fragilidad:', ' un fichero vivo se re-toca mucho sin estar roto):');
|
|
276
|
+
for (const r of i.retoqueRapido.slice(0, 3)) {
|
|
277
|
+
l.push(` ${corto(r.fichero).padEnd(47)} ${String(r.veces).padStart(3)}×`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
l.push('', ' ¿Quieres que tu agente sepa esto antes de editar? npx changebook init');
|
|
281
|
+
return l.join('\n');
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Versión del formato JSON. B4 (la tarjeta y el badge) va a consumir esta
|
|
285
|
+
* salida, y va a vivir en otro repo y en otra cadencia de despliegue: sin un
|
|
286
|
+
* número que mirar, el día que aquí se renombre un campo B4 se rompe leyendo
|
|
287
|
+
* `undefined` — en silencio, que es la peor forma. Súbelo al quitar o renombrar
|
|
288
|
+
* un campo; añadir uno nuevo no lo mueve.
|
|
289
|
+
*/
|
|
290
|
+
export const FORMATO_JSON = 1;
|
|
291
|
+
/**
|
|
292
|
+
* Los argumentos de `scan`, separados de `process.argv` para poder probarlos.
|
|
293
|
+
*
|
|
294
|
+
* POR QUÉ ES UNA FUNCIÓN Y NO DOS LÍNEAS EN EL `switch`. La primera versión de
|
|
295
|
+
* esto vivía en index.ts y el test lo comprobaba lanzando el binario
|
|
296
|
+
* CONSTRUIDO. Pasaba en mi máquina —tenía `dist/` hecho— y en CI reventó con
|
|
297
|
+
* «Cannot find module .../dist/index.js», porque la puerta hace `npm ci` pero
|
|
298
|
+
* no `npm run build`. Un test verde solo donde lo escribí.
|
|
299
|
+
*
|
|
300
|
+
* El arreglo no es construir el CLI en la puerta (la engorda para todos) ni
|
|
301
|
+
* saltarse el test cuando falta `dist/` (eso es el verde por vacío). El fallo
|
|
302
|
+
* que hay que vigilar —que `--json` a secas se tome por directorio— es lógica
|
|
303
|
+
* pura, así que se saca aquí y se prueba directamente.
|
|
304
|
+
*/
|
|
305
|
+
export function argumentosDeScan(argv) {
|
|
306
|
+
return {
|
|
307
|
+
// El directorio es el primer argumento que NO sea una bandera.
|
|
308
|
+
dir: argv.find((a) => !a.startsWith('--')) ?? process.cwd(),
|
|
309
|
+
json: argv.includes('--json'),
|
|
310
|
+
card: argv.includes('--card'),
|
|
311
|
+
// `--badge` es la ÚNICA bandera de `scan` que necesita cuenta y red: publica
|
|
312
|
+
// los números para la imagen del README. El cálculo sigue siendo el mismo y
|
|
313
|
+
// sigue viviendo aquí; lo que toca la red está en `badge.ts`, para que la
|
|
314
|
+
// promesa de este módulo —sin credenciales, sin red, sin escribir— siga
|
|
315
|
+
// siendo cierta para las otras tres formas de llamarlo.
|
|
316
|
+
badge: argv.includes('--badge'),
|
|
317
|
+
// Apagar es tan barato como encender, y va en el mismo comando a propósito:
|
|
318
|
+
// un interruptor que se enciende desde la terminal y se apaga solo desde la
|
|
319
|
+
// web es un interruptor que la gente no se atreve a tocar.
|
|
320
|
+
off: argv.includes('--off'),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** XML no perdona: una ruta con `&` o `<` rompe el SVG entero, en silencio. */
|
|
324
|
+
const escapar = (s) => s
|
|
325
|
+
.replace(/&/g, '&')
|
|
326
|
+
.replace(/</g, '<')
|
|
327
|
+
.replace(/>/g, '>')
|
|
328
|
+
.replace(/"/g, '"');
|
|
329
|
+
// ── La tarjeta (B4) ─────────────────────────────────────────────────────────
|
|
330
|
+
//
|
|
331
|
+
// «El scan produce algo que se puede tuitear.» Sale por STDOUT como el `--json`,
|
|
332
|
+
// no a un fichero: `scan` promete no escribir nada en el repo que analiza y hay
|
|
333
|
+
// un test que lo comprueba, así que la tarjeta se redirige
|
|
334
|
+
// (`changebook scan --card > tarjeta.svg`). De paso es más Unix.
|
|
335
|
+
//
|
|
336
|
+
// AUTOCONTENIDA A PROPÓSITO: sin fuentes remotas, sin imágenes enlazadas, sin
|
|
337
|
+
// CSS externo. Una tarjeta que se comparte y se ve rota en la máquina de otro no
|
|
338
|
+
// sirve de nada, y cualquier recurso remoto es además un rastreador que nadie
|
|
339
|
+
// pidió.
|
|
340
|
+
//
|
|
341
|
+
// Y EL SUJETO ES EL MÓDULO, NUNCA EL AUTOR (invariante 6: «es lo que convirtió a
|
|
342
|
+
// Code Climate Velocity en herramienta de vigilancia percibida»). Aquí se cumple
|
|
343
|
+
// por construcción y no por disciplina: `leerHistorial` no lee el autor de los
|
|
344
|
+
// commits, así que no hay nombre que enseñar aunque alguien quisiera.
|
|
345
|
+
//
|
|
346
|
+
// TAMPOCO DICE «FRAGILIDAD», que es lo que pedía la ficha. Llamar frágil a lo
|
|
347
|
+
// que se toca mucho es el error del contador de reincidencia con otra ropa, y
|
|
348
|
+
// aquí sería público. Dice acoplamiento, que es lo que se mide.
|
|
349
|
+
export function tarjetaSVG(i) {
|
|
350
|
+
const parejas = i.parejas.slice(0, 3);
|
|
351
|
+
const corta = (f, n = 30) => escapar(f.length <= n ? f : `…${f.slice(-(n - 1))}`);
|
|
352
|
+
// GEOMETRÍA MEDIDA, no estimada. La primera versión usaba 62 px de paso para
|
|
353
|
+
// filas de DOS líneas: el segundo fichero de una fila quedaba a 36 px del
|
|
354
|
+
// porcentaje de la siguiente y el bloque se leía apelotonado. Y el contenido
|
|
355
|
+
// acababa sobre los 460 de un lienzo de 630, con media tarjeta vacía debajo.
|
|
356
|
+
// 84 de paso y arranque en 372 reparten el alto entero.
|
|
357
|
+
const PASO = 78;
|
|
358
|
+
const filas = parejas
|
|
359
|
+
.map((p, n) => {
|
|
360
|
+
const y = 372 + n * PASO;
|
|
361
|
+
return ` <text x="80" y="${y}" class="pct">${Math.round(p.tasa * 100)}%</text>
|
|
362
|
+
<text x="200" y="${y}" class="par ink">${corta(p.a)}</text>
|
|
363
|
+
<text x="200" y="${y + 30}" class="par dim">${corta(p.b)}</text>`;
|
|
364
|
+
})
|
|
365
|
+
.join('\n');
|
|
366
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="Informe de acoplamiento de ${escapar(i.proyecto)}">
|
|
367
|
+
<style>
|
|
368
|
+
.bg{fill:#131211}.ink{fill:#e7e4df}.dim{fill:#9a938a}.accent{fill:#e8896f}
|
|
369
|
+
text{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif}
|
|
370
|
+
.t{font-size:44px;font-weight:600}.sub{font-size:22px}
|
|
371
|
+
.pct{font-size:34px;font-weight:600;fill:#e8896f}
|
|
372
|
+
.par{font-size:23px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
373
|
+
.hd{font-size:19px;letter-spacing:.09em}.mark{font-size:19px}
|
|
374
|
+
</style>
|
|
375
|
+
<rect class="bg" width="1200" height="630"/>
|
|
376
|
+
<rect class="accent" x="0" y="0" width="1200" height="6"/>
|
|
377
|
+
<text class="t ink" x="80" y="132">${escapar(i.proyecto)}</text>
|
|
378
|
+
<text class="sub dim" x="80" y="180">${i.commits} commits · ${i.ficherosVivos} ficheros · ${escapar(String(i.desde))} → ${escapar(String(i.hasta))}</text>
|
|
379
|
+
<text class="sub ink" x="80" y="246">El ${i.concentracion.porcentaje}% de sus ficheros concentra la mitad de los cambios.</text>
|
|
380
|
+
<line x1="80" y1="292" x2="1120" y2="292" stroke="#2e2b27" stroke-width="1"/>
|
|
381
|
+
<text class="hd dim" x="80" y="322">CAMBIAN JUNTOS — SI TOCAS UNO, MIRA EL OTRO</text>
|
|
382
|
+
${filas}
|
|
383
|
+
<text class="mark dim" x="80" y="600">Generado con ChangeBook · changebook.app</text>
|
|
384
|
+
</svg>
|
|
385
|
+
`;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* El informe para el final de `init` (B8): «instalar es descubrir, no esperar».
|
|
389
|
+
*
|
|
390
|
+
* FALLA ABIERTO A PROPÓSITO. `init` se corre en directorios que pueden no ser un
|
|
391
|
+
* repo de git, o serlo y estar vacíos, y su trabajo de verdad —registrar el MCP,
|
|
392
|
+
* los hooks, el sync— ya está hecho cuando esto se ejecuta. Reventar aquí
|
|
393
|
+
* convertiría una instalación correcta en una que parece fallida. Devuelve
|
|
394
|
+
* `null` y `init` sigue: exactamente la misma regla que el hook de impacto.
|
|
395
|
+
*/
|
|
396
|
+
export async function informeParaInit(dir) {
|
|
397
|
+
try {
|
|
398
|
+
const commits = await leerHistorial(dir);
|
|
399
|
+
if (commits.length === 0)
|
|
400
|
+
return null;
|
|
401
|
+
return informeDeScan(analizar(path.basename(path.resolve(dir)), commits));
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/** Punto de entrada del comando. Lectura pura: no escribe, no llama a la red. */
|
|
408
|
+
/**
|
|
409
|
+
* El informe en crudo, para quien necesita los NÚMEROS y no el texto.
|
|
410
|
+
*
|
|
411
|
+
* Lo usa `--badge`, que publica cifras: pedirle a `correrScan` una cadena y
|
|
412
|
+
* volver a parsearla sería inventar un formato intermedio entre dos funciones
|
|
413
|
+
* del mismo fichero.
|
|
414
|
+
*/
|
|
415
|
+
export async function informeDeRepo(dir) {
|
|
416
|
+
const commits = await leerHistorial(dir);
|
|
417
|
+
return analizar(path.basename(path.resolve(dir)), commits);
|
|
418
|
+
}
|
|
419
|
+
export async function correrScan(dir, opciones = {}) {
|
|
420
|
+
const informe = await informeDeRepo(dir);
|
|
421
|
+
if (opciones.card)
|
|
422
|
+
return tarjetaSVG(informe);
|
|
423
|
+
return opciones.json
|
|
424
|
+
? JSON.stringify({ formato: FORMATO_JSON, ...informe }, null, 2)
|
|
425
|
+
: informeDeScan(informe);
|
|
426
|
+
}
|
|
427
|
+
//# sourceMappingURL=scan.js.map
|
package/dist/supabase.js
CHANGED
|
@@ -183,6 +183,45 @@ export class Supabase {
|
|
|
183
183
|
* POST one row via PostgREST as the signed-in user (RLS applies). Used for
|
|
184
184
|
* best-effort metering inserts — callers typically fire-and-forget it.
|
|
185
185
|
*/
|
|
186
|
+
/**
|
|
187
|
+
* La base del despliegue, para construir URLs públicas (el badge). Solo
|
|
188
|
+
* lectura: `url` sigue siendo privada porque nadie de fuera tiene por qué
|
|
189
|
+
* poder reapuntar el cliente a otro sitio a mitad de camino.
|
|
190
|
+
*/
|
|
191
|
+
get baseUrl() {
|
|
192
|
+
return this.url;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* INSERT que actualiza si ya existe. `onConflict` es la columna única sobre la
|
|
196
|
+
* que se decide: sin ella PostgREST no sabe qué fila estaba pisando y
|
|
197
|
+
* devuelve un 23505 que se lee como "algo falló" cuando en realidad es
|
|
198
|
+
* "vuelve a publicar tus números", que es el caso normal.
|
|
199
|
+
*/
|
|
200
|
+
async upsertRow(table, row, onConflict) {
|
|
201
|
+
if (!this.hasCredentials())
|
|
202
|
+
throw new SupabaseError(AUTH_HELP, 401);
|
|
203
|
+
if (!this.accessToken)
|
|
204
|
+
await this.refresh();
|
|
205
|
+
const enviar = () => fetch(`${this.url}/rest/v1/${table}?on_conflict=${encodeURIComponent(onConflict)}`, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: {
|
|
208
|
+
apikey: this.anonKey,
|
|
209
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
210
|
+
'Content-Type': 'application/json',
|
|
211
|
+
Prefer: 'resolution=merge-duplicates,return=minimal',
|
|
212
|
+
},
|
|
213
|
+
body: JSON.stringify(row),
|
|
214
|
+
signal: AbortSignal.timeout(10_000),
|
|
215
|
+
});
|
|
216
|
+
let res = await enviar();
|
|
217
|
+
if (res.status === 401 && this.refreshToken) {
|
|
218
|
+
await this.refresh();
|
|
219
|
+
res = await enviar();
|
|
220
|
+
}
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
throw new SupabaseError(`Upsert into ${table} failed (${res.status}): ${(await res.text()).slice(0, 200)}`, res.status);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
186
225
|
async insertRow(table, row) {
|
|
187
226
|
if (!this.hasCredentials())
|
|
188
227
|
throw new SupabaseError(AUTH_HELP, 401);
|
|
@@ -197,6 +236,44 @@ export class Supabase {
|
|
|
197
236
|
throw new SupabaseError(`Insert into ${table} failed (${res.status}): ${(await res.text()).slice(0, 200)}`, res.status);
|
|
198
237
|
}
|
|
199
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* PATCH a PostgREST path as the signed-in user. `pathWithQuery` HAS to carry
|
|
241
|
+
* the filter (`regression_alerts?id=eq.<uuid>`): PostgREST rejects an
|
|
242
|
+
* unfiltered PATCH, and that rejection is the only thing standing between a
|
|
243
|
+
* typo and rewriting the whole table.
|
|
244
|
+
*
|
|
245
|
+
* The column grants still apply — `regression_alerts` only allows
|
|
246
|
+
* `resolved_at, resolution, resolution_by` to `authenticated`, on purpose, so
|
|
247
|
+
* a client cannot rewrite `module` or `project_id` and move an alert into
|
|
248
|
+
* someone else's project.
|
|
249
|
+
*/
|
|
250
|
+
async patchRows(table, filter, patch) {
|
|
251
|
+
if (!this.hasCredentials())
|
|
252
|
+
throw new SupabaseError(AUTH_HELP, 401);
|
|
253
|
+
if (!this.accessToken)
|
|
254
|
+
await this.refresh();
|
|
255
|
+
let res = await this.patchOnce(table, filter, patch);
|
|
256
|
+
if (res.status === 401 && this.refreshToken) {
|
|
257
|
+
await this.refresh();
|
|
258
|
+
res = await this.patchOnce(table, filter, patch);
|
|
259
|
+
}
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
throw new SupabaseError(`Patch on ${table} failed (${res.status}): ${(await res.text()).slice(0, 200)}`, res.status);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
patchOnce(table, filter, patch) {
|
|
265
|
+
return fetch(`${this.url}/rest/v1/${table}?${filter}`, {
|
|
266
|
+
method: 'PATCH',
|
|
267
|
+
headers: {
|
|
268
|
+
apikey: this.anonKey,
|
|
269
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
270
|
+
'Content-Type': 'application/json',
|
|
271
|
+
Prefer: 'return=minimal',
|
|
272
|
+
},
|
|
273
|
+
body: JSON.stringify(patch),
|
|
274
|
+
signal: AbortSignal.timeout(10_000),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
200
277
|
/** POST /rest/v1/rpc/<fn> as the signed-in user (definer rules apply). */
|
|
201
278
|
async callRpc(fn, args) {
|
|
202
279
|
if (!this.hasCredentials())
|
|
@@ -211,7 +288,13 @@ export class Supabase {
|
|
|
211
288
|
if (!res.ok) {
|
|
212
289
|
throw new SupabaseError(`RPC ${fn} failed (${res.status}): ${(await res.text()).slice(0, 200)}`, res.status);
|
|
213
290
|
}
|
|
214
|
-
|
|
291
|
+
// Una RPC que devuelve `void` contesta 204 con el cuerpo VACÍO, y
|
|
292
|
+
// `res.json()` explota ahí con «Unexpected end of JSON input» — un error que
|
|
293
|
+
// no dice nada del problema real y que aparece DESPUÉS de que la escritura
|
|
294
|
+
// ya haya ocurrido, o sea que el usuario ve un fallo de algo que sí se hizo.
|
|
295
|
+
// Medido llamando a `badge_publicar`.
|
|
296
|
+
const texto = await res.text();
|
|
297
|
+
return (texto ? JSON.parse(texto) : null);
|
|
215
298
|
}
|
|
216
299
|
rpcOnce(fn, args) {
|
|
217
300
|
return fetch(`${this.url}/rest/v1/rpc/${fn}`, {
|