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/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, '&amp;')
326
+ .replace(/</g, '&lt;')
327
+ .replace(/>/g, '&gt;')
328
+ .replace(/"/g, '&quot;');
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
- return (await res.json());
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}`, {