changebook 0.7.1 → 0.9.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/README.md CHANGED
@@ -53,6 +53,11 @@ your other servers.
53
53
  | `changebook hook-context install\|uninstall\|status [dir]` | Claude Code `SessionStart` hook: pushes the fresh atlas map into **every** session at turn 0 — no tool call to remember, and generated on the spot so it can't go stale. Writes to `.claude/settings.json`; running the command **is** the consent, and it refuses to touch a config it can't parse. |
54
54
  | `changebook hook-impact install\|uninstall\|status [dir]` | Claude Code `PreToolUse` hook: **before every edit**, tells the agent which modules depend on the file it is about to touch, plus any open alert and repeat-offender history. Never blocks an edit, never touches the network on the critical path (reads a short-lived cache in `.git/` and refreshes it out of band), warns once per file per session, and stays **silent** when there is nothing to say. |
55
55
  | `changebook impact` / `changebook context [dir]` | What those two hooks run. Both read from stdin/disk, print a JSON payload (or nothing) and always exit 0 — you don't call them by hand. |
56
+ | `changebook import [dir] [--commits N]` | Backfill the last N commits (default 25) through the Anthropic Batch API — 50% cheaper and non-interactive, for seeding the atlas on an existing repo. |
57
+ | `changebook scan [dir] [--json\|--card\|--badge]` | Coupling report for **any** repo from its git history alone: no account, no network, writes nothing — run it on something you just cloned. `--card` renders a shareable SVG, `--badge` publishes four numbers and prints the README snippet (needs an account; `--badge --off` turns it off). The badge exposes those four numbers and nothing else — not your code, modules or change summaries. |
58
+ | `changebook silence [dir]` | How often the `PreToolUse` hook stays quiet, with both raw numbers. Local by design: it answers the day you install it, not two days later. |
59
+ | `changebook friction [dir]` | Where the agent's work gets redone in this repo, read from the **local** Claude Code transcripts — no prose leaves the machine, only paths, modules, dates and a session hash. Says **MUERTO** if the repo has edits and it read nothing, and reports its own blind spot: edits made through the shell (`sed -i`, heredocs) leave no before/after, so ~25% of writes are invisible to it and it says so. |
60
+ | `changebook audit [dir]` | Static check of your agent setup — no network, no credentials, nothing written. Flags rules in `CLAUDE.md`/`AGENTS.md` that cite files which no longer exist (with a «did you mean…»), how much context you pay every session, and whether the impact hook is actually installed. Every check here was written after it found something real, not from a best-practices list. |
56
61
  | `changebook guard [dir]` | What the pre-commit hook runs: checks staged files against the atlas' open alerts. Warn-only and fail-open by default; `CHANGEBOOK_GUARD=block` makes findings abort the commit (bypass once with `git commit --no-verify`), `CHANGEBOOK_GUARD=off` silences it. |
57
62
  | `changebook sync [dir]` | Refresh the product map inside `CLAUDE.md`/`AGENTS.md`. |
58
63
  | `changebook init [dir]` | login + register MCP server + install hook + sync, in one go. |
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Los agregados del atlas: funciones puras sobre las filas que sirve el MCP.
3
+ *
4
+ * POR QUE EXISTE. Espejo de `supabase/functions/mcp/scope.ts`. Salen de
5
+ * `tools.ts` el 09/08 para que una herramienta pueda vivir en su propio fichero
6
+ * sin cerrar un ciclo: `toolProjectBrief.ts` necesita `briefModules` y
7
+ * `aggregateFileContext`, y si los pidiera a `tools.ts` -que a su vez lo importa
8
+ * para registrarlo- el modulo se importaria a si mismo por el camino largo.
9
+ *
10
+ * Aqui NO entra nada que sepa del repo local (git, process.cwd) ni de una tool
11
+ * concreta: eso se queda en `tools.ts`. `recordRead` es la unica excepcion y
12
+ * viaja porque la usan TODAS las herramientas y solo necesita el cliente.
13
+ *
14
+ * Varias de estas son espejos del hospedado y su paridad esta fijada por
15
+ * contratos que comparan SALIDAS, no menciones: ver mcpParity, espejoDeCommits,
16
+ * radioDeImpacto y elPaqueteNoPideToolsQueNoTiene.
17
+ */
18
+ import { etiquetaPorSlug, slugModule } from "./aliasDeModulo.js";
19
+ // Pre-edit lookup helpers (mirror of the hosted scope.ts — the npm package
20
+ // must stay self-contained, so these three stay tiny and duplicated).
21
+ // Exported so test/mcpParity.test.ts can pin them equal to the hosted copies:
22
+ // drift would break atlas_file_context on stdio silently (audit M7).
23
+ export function normalizeRepoPath(path) {
24
+ return path.trim().replace(/^\.\//, "").replace(/^\/+/, "");
25
+ }
26
+ export function fileContainsFilter(path) {
27
+ return `files=cs.${encodeURIComponent(JSON.stringify([path]))}`;
28
+ }
29
+ export function quotedInList(values) {
30
+ return values
31
+ .map((v) => `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
32
+ .join(",");
33
+ }
34
+ /**
35
+ * Filtro PostgREST para resolver alertas de regresión. Espejo de
36
+ * supabase/functions/mcp/scope.ts::resolveAlertFilter — paridad de SALIDAS
37
+ * fijada en test/elPaqueteNoPideToolsQueNoTiene.test.ts.
38
+ *
39
+ * `userId` es OPCIONAL aquí y obligatorio allí, y la diferencia no es un
40
+ * descuido: el hospedado consulta con la service key, que se salta RLS, así que
41
+ * el aislamiento de cuenta tiene que escribirlo él a mano en el filtro. Este
42
+ * servidor consulta con el token del usuario, y RLS ya acota cada fila a su
43
+ * dueño. Añadir aquí un `user_id` que no tenemos —el cliente no conoce su
44
+ * propio uuid sin una llamada extra— sería pedirle a PostgREST que filtre por
45
+ * `undefined`, que es peor que no filtrar: casa con cero filas y se lee como
46
+ * «no había nada que cerrar».
47
+ *
48
+ * Se acepta el parámetro para poder comparar las dos implementaciones con la
49
+ * MISMA entrada en un test: sin él, la paridad solo podría afirmarse a medias,
50
+ * que es exactamente lo que la invariante 16 prohíbe.
51
+ */
52
+ export function resolveAlertFilter(input) {
53
+ return ((input.userId ? `user_id=eq.${input.userId}&` : "") +
54
+ `project_id=eq.${input.projectId}` +
55
+ `&module=eq.${encodeURIComponent(input.module)}` +
56
+ `&resolved_at=is.null` +
57
+ (input.symbol
58
+ ? `&evidence_symbol=eq.${encodeURIComponent(input.symbol)}`
59
+ : ""));
60
+ }
61
+ /**
62
+ * El contexto de UN fichero: qué módulos lo tocan, cuántas veces y con qué nota.
63
+ *
64
+ * Espejo de supabase/functions/mcp/scope.ts::aggregateFileContext — paridad de
65
+ * SALIDAS fijada en test/elPaqueteNoPideToolsQueNoTiene.test.ts.
66
+ *
67
+ * ⚠ SE EXTRAE Y ADEMÁS ARREGLA. Esto vivía escrito a mano dentro de
68
+ * `atlas_file_context` y agrupaba por NOMBRE CRUDO (`byModule.get(name)`),
69
+ * mientras el hospedado agrupaba por slug. O sea que en stdio un módulo escrito
70
+ * de dos formas —`Núcleo` y `Nucleo`— servía DOS bloques para el mismo fichero,
71
+ * cada uno con la mitad de los cambios, y el agente los leía como dos módulos
72
+ * que no se conocen. Es la invariante 16 exacta: media normalización se lee
73
+ * igual que una entera, y `canonicalizeModuleRows` resuelve ALIAS, no grafías.
74
+ *
75
+ * El alias se sigue resolviendo antes de llamar aquí; esto es la otra mitad.
76
+ */
77
+ export function aggregateFileContext(file, rows) {
78
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
79
+ const byModule = new Map();
80
+ const changelogIds = [];
81
+ for (const row of rows) {
82
+ if (row.changelog_id && !changelogIds.includes(row.changelog_id)) {
83
+ changelogIds.push(row.changelog_id);
84
+ }
85
+ const name = (row.module ?? "").trim();
86
+ if (!name)
87
+ continue;
88
+ const slug = slugModule(name);
89
+ const existing = byModule.get(slug);
90
+ if (existing) {
91
+ existing.changes += 1;
92
+ }
93
+ else {
94
+ byModule.set(slug, {
95
+ module: etiqueta.get(slug) ?? name,
96
+ risk: row.risk,
97
+ changes: 1,
98
+ last_changed: row.created_at.slice(0, 10),
99
+ last_note: row.note,
100
+ });
101
+ }
102
+ }
103
+ return { file, modules: [...byModule.values()], changelogIds };
104
+ }
105
+ /**
106
+ * El mapa de módulos del brief: los `max` más recientes, y CUÁNTOS hay.
107
+ *
108
+ * Espejo de supabase/functions/mcp/scope.ts::briefModules — paridad de SALIDAS
109
+ * fijada en test/elPaqueteNoPideToolsQueNoTiene.test.ts.
110
+ *
111
+ * AGRUPA POR SLUG, no por el nombre crudo, y ahí estaba el bug que más mentía:
112
+ * `total` contaba 80 módulos donde había 76, y cuatro de ellos eran la mitad
113
+ * pequeña de un módulo partido por una tilde o una mayúscula (invariante 16).
114
+ *
115
+ * Y `total` NO es «todos los módulos del proyecto»: es cuántos distintos hay en
116
+ * la VENTANA de filas que se le pasa. El brief lo dice con esas palabras porque
117
+ * medido el 03/08 la ventana estaba llena al ras, o sea que el número BAJA
118
+ * según se trabaja, y llamarlo «total» hacía leer eso como módulos que
119
+ * desaparecen.
120
+ */
121
+ export function briefModules(rows, max = 15) {
122
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
123
+ const byModule = new Map();
124
+ for (const row of rows) {
125
+ const name = (row.module ?? "").trim();
126
+ if (!name)
127
+ continue;
128
+ const slug = slugModule(name);
129
+ const existing = byModule.get(slug);
130
+ if (existing) {
131
+ existing.changes += 1;
132
+ }
133
+ else {
134
+ byModule.set(slug, {
135
+ module: etiqueta.get(slug) ?? name,
136
+ domain: row.domain ?? null,
137
+ risk: row.risk,
138
+ changes: 1,
139
+ last_changed: row.created_at.slice(0, 10),
140
+ });
141
+ }
142
+ }
143
+ return { modules: [...byModule.values()].slice(0, max), total: byModule.size };
144
+ }
145
+ /** El uuid del proyecto dentro del filtro que devuelve `projectFilterFor`. */
146
+ export function projectIdFromFilter(filter) {
147
+ return /project_id=eq\.([0-9a-f-]+)/.exec(filter)?.[1] ?? null;
148
+ }
149
+ export const FILES_CAP = 8;
150
+ const MAX_DEPENDENTS = 6;
151
+ /** Espejo de MODULE_COUNT_WINDOW_ROWS (supabase/functions/mcp/scope.ts).
152
+ * El mapa de la web dibuja las flechas sobre esta misma ventana: dos
153
+ * ventanas distintas darían dependencias distintas según se mire el dibujo
154
+ * o se pregunte al atlas. Paridad fijada en test/radioDeImpacto. */
155
+ export const MODULE_GRAPH_WINDOW_ROWS = 1000;
156
+ export function dependentsOf(targets, rows) {
157
+ // POR SLUG, NO POR toLowerCase(): media normalizacion se lee igual que una
158
+ // entera (invariante 16). Espejo de supabase/functions/mcp/scope.ts.
159
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
160
+ // Memo de slugModule: se llama una vez por fila y por dep. Espejo de
161
+ // supabase/functions/mcp/scope.ts (medido: 0,510 ms -> 2,698 ms sin esto).
162
+ const memo = new Map();
163
+ const slug = (s) => {
164
+ let v = memo.get(s);
165
+ if (v === undefined) {
166
+ v = slugModule(s);
167
+ memo.set(s, v);
168
+ }
169
+ return v;
170
+ };
171
+ const graph = new Map();
172
+ for (const r of rows) {
173
+ const label = (r.module ?? "").trim();
174
+ if (!label || graph.has(slug(label)))
175
+ continue;
176
+ const deps = Array.isArray(r.deps)
177
+ ? r.deps.map((d) => (typeof d === "string" ? d.trim() : "")).filter(Boolean)
178
+ : [];
179
+ graph.set(slug(label), deps);
180
+ }
181
+ const reverse = new Map();
182
+ for (const r of rows) {
183
+ const crudo = (r.module ?? "").trim();
184
+ if (!crudo)
185
+ continue;
186
+ const slugFrom = slug(crudo);
187
+ const from = etiqueta.get(slugFrom) ?? crudo;
188
+ for (const to of graph.get(slugFrom) ?? []) {
189
+ const key = slug(to);
190
+ if (key === slugFrom)
191
+ continue;
192
+ const set = reverse.get(key) ?? new Set();
193
+ set.add(from);
194
+ reverse.set(key, set);
195
+ }
196
+ }
197
+ const out = new Map();
198
+ for (const t of targets) {
199
+ const label = (t ?? "").trim();
200
+ if (!label)
201
+ continue;
202
+ const found = reverse.get(slug(label));
203
+ if (!found || found.size === 0)
204
+ continue;
205
+ out.set(label, [...found].sort((a, b) => a.localeCompare(b)).slice(0, MAX_DEPENDENTS));
206
+ }
207
+ return out;
208
+ }
209
+ /**
210
+ * Reincidencia (espejo de supabase/functions/mcp/scope.ts::computeRecidivism —
211
+ * paridad en test/reincidenciaFileContext). Por módulo, nº de problemas de
212
+ * regresión DISTINTOS (count(distinct plain), all-time), solo los con
213
+ * antecedentes (>= 2). Plain distinto, no filas, para que el re-levantado no
214
+ * infle. No se refuta: cuenta historia, no vigencia.
215
+ */
216
+ /**
217
+ * ESPEJO de `RecidivismMap` en supabase/functions/mcp/scope.ts. Un Map de
218
+ * verdad —se itera y se muestra con la etiqueta buena— cuyo `get`/`has`
219
+ * normalizan la clave por slug antes de buscar, para que ningún consumidor
220
+ * tenga que acordarse de canonicalizar. Ver allí el razonamiento entero.
221
+ */
222
+ export class RecidivismMap extends Map {
223
+ #porSlug = new Map();
224
+ set(clave, valor) {
225
+ this.#porSlug.set(slugModule(clave), valor);
226
+ return super.set(clave, valor);
227
+ }
228
+ get(clave) {
229
+ return super.get(clave) ?? this.#porSlug.get(slugModule(clave));
230
+ }
231
+ has(clave) {
232
+ return super.has(clave) || this.#porSlug.has(slugModule(clave));
233
+ }
234
+ }
235
+ export function computeRecidivism(rows) {
236
+ // Agrupado por slug y presentado con la etiqueta representante: si las
237
+ // alertas de un módulo partido están escritas con las dos grafías, sus
238
+ // regresiones distintas son las de UN módulo, no las de dos.
239
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
240
+ const plainsByModule = new Map();
241
+ for (const r of rows) {
242
+ // SOLO LAS CONFIRMADAS. Hasta el 2026-08-02 esto contaba TODA fila de
243
+ // `regression_alerts`, o sea avisos que el propio atlas se generó: sumaba
244
+ // los confirmados, los que el dueño refutó, los que se cerraron solos al
245
+ // re-tocar el módulo y los 79 que son anteriores al campo `resolution`.
246
+ // Medido en producción: decía 110 y las confirmadas eran 20 — «Análisis de
247
+ // cambios» salía con 13 y tenía CERO.
248
+ //
249
+ // La migración que añadió `resolution` (20260725100000) dice en su propio
250
+ // comentario que existe para impedir «el número bonito y falso». Este
251
+ // contador nunca lo adoptó. Ahora sí.
252
+ //
253
+ // `fixed` lo escribe el agente que actuó sobre el aviso, no un oráculo
254
+ // independiente, así que sigue siendo autocertificado — pero es la mejor
255
+ // etiqueta que hay, y desde luego mejor que no mirar.
256
+ if ((r.resolution ?? "").trim().toLowerCase() !== "fixed")
257
+ continue;
258
+ const crudo = (r.module ?? "").trim();
259
+ const p = (r.plain ?? "").trim();
260
+ if (!crudo || !p)
261
+ continue;
262
+ const m = etiqueta.get(slugModule(crudo)) ?? crudo;
263
+ const set = plainsByModule.get(m) ?? new Set();
264
+ set.add(p);
265
+ plainsByModule.set(m, set);
266
+ }
267
+ const out = new RecidivismMap();
268
+ for (const [m, set] of plainsByModule) {
269
+ if (set.size >= 2)
270
+ out.set(m, set.size);
271
+ }
272
+ return out;
273
+ }
274
+ export function filesUnionByChange(rows, cap = FILES_CAP) {
275
+ const acc = new Map();
276
+ for (const r of rows) {
277
+ if (!Array.isArray(r.files))
278
+ continue;
279
+ let list = acc.get(r.changelog_id);
280
+ if (!list) {
281
+ list = [];
282
+ acc.set(r.changelog_id, list);
283
+ }
284
+ for (const f of r.files) {
285
+ if (typeof f === "string" && f.length > 0 && !list.includes(f)) {
286
+ list.push(f);
287
+ }
288
+ }
289
+ }
290
+ const out = new Map();
291
+ for (const [id, list] of acc) {
292
+ out.set(id, {
293
+ files: list.slice(0, cap),
294
+ more: Math.max(0, list.length - cap),
295
+ });
296
+ }
297
+ return out;
298
+ }
299
+ // Espejo de supabase/functions/mcp/scope.ts (paridad en
300
+ // test/espejoDeCommits.test.ts): aliases post-squash del mismo contenido,
301
+ // para que el hash citado exista en el main del consultante.
302
+ export function commitAliasesShort(raw) {
303
+ if (!Array.isArray(raw))
304
+ return [];
305
+ return raw
306
+ .filter((h) => typeof h === "string" && /^[0-9a-f]{7,64}$/i.test(h))
307
+ .map((h) => h.slice(0, 7));
308
+ }
309
+ export function commitLabel(hash, aliasesRaw) {
310
+ const corto = hash?.slice(0, 7) ?? null;
311
+ if (!corto)
312
+ return null;
313
+ const aliases = commitAliasesShort(aliasesRaw);
314
+ return aliases.length > 0 ? `${corto} (=${aliases.join(",")})` : corto;
315
+ }
316
+ export const FILE_COMMITS_CAP = 5;
317
+ export function recentCommitsForFile(changelogIds, commitById, cap = FILE_COMMITS_CAP) {
318
+ const all = changelogIds
319
+ .map((id) => commitById.get(id))
320
+ .filter((c) => Boolean(c));
321
+ return { commits: all.slice(0, cap), more: Math.max(0, all.length - cap) };
322
+ }
323
+ // Consultation metering (never billing): each successful read leaves a row in
324
+ // atlas_reads so the web can show "your agent consulted the atlas N times".
325
+ // Best-effort and non-blocking — metering must never break or slow a read.
326
+ // user_id is filled server-side (column default auth.uid()).
327
+ export function recordRead(db, tool, projectFilter, charsServed, latencyMs) {
328
+ const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
329
+ void db
330
+ .insertRow("atlas_reads", {
331
+ project_id: projectId,
332
+ tool,
333
+ source: "stdio",
334
+ chars_served: charsServed,
335
+ // Latencia del handler (encargo 0dbbfbf4): nullable a propósito — es
336
+ // metering, jamás contrato, y un servidor viejo simplemente no la manda.
337
+ ...(latencyMs !== undefined
338
+ ? { latency_ms: Math.max(0, Math.min(Math.round(latencyMs), 600000)) }
339
+ : {}),
340
+ })
341
+ .catch(() => { });
342
+ }
343
+ /** Reincidente a partir de 3 regresiones distintas. Espejo del hospedado. */
344
+ const RECIDIVIST_MIN = 3;
345
+ /** Prefijo normalizado por el que dos avisos se consideran el mismo. */
346
+ const FIRMA_CHARS = 120;
347
+ /**
348
+ * La firma de un aviso: su prefijo normalizado, sin tildes ni puntuacion.
349
+ * Espejo de supabase/functions/mcp/scope.ts.
350
+ */
351
+ export function firmaDeAviso(plain) {
352
+ return plain
353
+ .toLowerCase()
354
+ .normalize("NFD")
355
+ .replace(/[̀-ͯ]/g, "")
356
+ .replace(/[^a-z0-9]+/g, " ")
357
+ .trim()
358
+ .slice(0, FIRMA_CHARS);
359
+ }
360
+ /**
361
+ * Un aviso por FIRMA, no por texto exacto.
362
+ *
363
+ * `unoPorTexto` ya colapsaba los identicos y no bastaba: dos avisos que
364
+ * comparten las primeras ~200 letras y divergen en la ultima frase se servian
365
+ * los DOS y abrian la pantalla. Para quien lo lee es una repeticion, y un
366
+ * avisador que repite se ignora igual que uno que se equivoca.
367
+ *
368
+ * SOBREVIVE EL MAS LARGO, y eso es lo que lo hace seguro: si dos coinciden al
369
+ * principio y difieren al final, se queda el que trae la diferencia. Colapsar no
370
+ * puede costar informacion que solo estaba en uno.
371
+ *
372
+ * ES LA PANTALLA, NO LA IDENTIDAD: el dedup de creacion sigue distinguiendo por
373
+ * (simbolo, expectativa), porque «tiene que seguir» y «tiene que desaparecer»
374
+ * son afirmaciones opuestas y colapsarlas escondería una regresion real.
375
+ */
376
+ export function colapsaAvisosParecidos(avisos) {
377
+ const mejor = new Map();
378
+ for (const a of avisos) {
379
+ const plain = (a.plain ?? "").trim();
380
+ if (!plain)
381
+ continue;
382
+ const clave = firmaDeAviso(plain);
383
+ if (!clave)
384
+ continue;
385
+ const actual = mejor.get(clave);
386
+ if (!actual || plain.length > (actual.plain ?? "").trim().length) {
387
+ mejor.set(clave, a);
388
+ }
389
+ }
390
+ return [...mejor.values()];
391
+ }
392
+ /**
393
+ * El plan de accion: una sola lista priorizada, lo mas urgente primero.
394
+ *
395
+ * POR TIERS EXPLICITOS Y NO POR UNA PUNTUACION. Un numero oculto obliga a
396
+ * confiar; cuatro tiers con nombre dejan discutir el orden. Espejo de
397
+ * supabase/functions/mcp/scope.ts::buildActionPlan — paridad de SALIDAS fijada
398
+ * en test/elPaqueteNoPideToolsQueNoTiene.
399
+ */
400
+ export function buildActionPlan(input) {
401
+ const items = [];
402
+ // Tier 1 — un cambio analizado ya rompio algo.
403
+ for (const a of colapsaAvisosParecidos(input.alerts)) {
404
+ const plain = (a.plain ?? "").trim();
405
+ if (!plain)
406
+ continue;
407
+ items.push({
408
+ tier: 1,
409
+ kind: "regression",
410
+ module: a.module,
411
+ what: `Regresión abierta${a.module ? ` en ${a.module}` : ""}`,
412
+ why: plain,
413
+ });
414
+ }
415
+ // Tier 2 — un control de salud que el analisis marco roto.
416
+ for (const h of input.atRiskHealth) {
417
+ items.push({
418
+ tier: 2,
419
+ kind: "health",
420
+ module: null,
421
+ what: `Control de salud en riesgo: ${h.check}`,
422
+ why: h.evidence,
423
+ check: h.check,
424
+ });
425
+ }
426
+ // Tier 3 — modulo marcado hotspot (fragil por diseno).
427
+ const seenHotspots = new Set();
428
+ for (const m of input.modules) {
429
+ if (m.risk !== "hotspot")
430
+ continue;
431
+ const previas = input.recidivism.get(m.module);
432
+ items.push({
433
+ tier: 3,
434
+ kind: "hotspot",
435
+ module: m.module,
436
+ what: `Módulo crítico (hotspot): ${m.module}`,
437
+ why: `${m.changes} cambio(s), últ. ${m.last_changed}` +
438
+ (previas ? ` · ${previas} regresiones previas` : ""),
439
+ changes: m.changes,
440
+ lastChanged: m.last_changed,
441
+ ...(previas ? { prior: previas } : {}),
442
+ });
443
+ seenHotspots.add(m.module);
444
+ }
445
+ // Tier 4 — reincidente que NO es ya hotspot (no duplicar).
446
+ for (const [mod, count] of input.recidivism) {
447
+ if (count >= RECIDIVIST_MIN && !seenHotspots.has(mod)) {
448
+ items.push({
449
+ tier: 4,
450
+ kind: "recidivist",
451
+ module: mod,
452
+ what: `Reincidente: ${mod}`,
453
+ why: `${count} regresiones distintas a lo largo del tiempo`,
454
+ count,
455
+ });
456
+ }
457
+ }
458
+ return items
459
+ .map((it, i) => ({ it, i }))
460
+ .sort((a, b) => a.it.tier - b.it.tier || a.i - b.i)
461
+ .map(({ it }, idx) => ({ ...it, rank: idx + 1 }));
462
+ }
463
+ /**
464
+ * La linea de precision que acompana al plan. `null` cuando no hay nada honesto
465
+ * que decir — y esa es la mitad importante: un porcentaje sin refutaciones mide
466
+ * quien juzga, no los avisos. Espejo del hospedado.
467
+ */
468
+ export function lineaDePrecision(p) {
469
+ if (!p)
470
+ return null;
471
+ const juzgadas = p.acertado + p.refutado + p.sin_tocar + p.caducado;
472
+ if (juzgadas === 0)
473
+ return null;
474
+ if (p.precision === null) {
475
+ const unSoloLado = p.motivo === "un_solo_lado" ||
476
+ (p.motivo == null &&
477
+ p.denominador >= p.minimo &&
478
+ (p.acertado === 0 || p.refutado === 0));
479
+ if (unSoloLado) {
480
+ const falta = p.refutado === 0 ? "none refuted" : "none confirmed";
481
+ return (`Warning accuracy: not published yet — ${p.denominador} judged, but ${falta}. ` +
482
+ `A score with only one kind of verdict in it measures the judging, not the warnings.`);
483
+ }
484
+ return (`Warning accuracy: not enough judged warnings yet ` +
485
+ `(${p.denominador} of ${p.minimo} needed). We publish the number only when it means something.`);
486
+ }
487
+ return (`Warning accuracy: ${Math.round(p.precision * 100)}% ` +
488
+ `(${p.acertado} of ${p.denominador} judged warnings were real). ` +
489
+ `Warnings nobody judged are excluded, not counted as misses.`);
490
+ }
491
+ /** Ventana de avisos «abiertos» del plan: 14 dias. Espejo de analysis.ts. */
492
+ const ATLAS_ALERT_WINDOW_DAYS = 14;
493
+ export function alertasDesde() {
494
+ return new Date(Date.now() - ATLAS_ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
495
+ }
496
+ //# sourceMappingURL=agregados.js.map