CreaRack-SL

bib_record_cochanges — MCP handler interno para registrar co-cambios git

Descripción

Handler MCP interno (no público, no está en TOOLS) que registra pares de archivos que cambian juntos en D1, enriqueciendo el grafo de la Biblioteca con señal empírica de acoplamiento derivada del historial git.

Firma: handler bib_record_cochanges(args) → JSON

Invocado por:

  • .github/scripts/bib_ingest.py · record_cochanges() post-merge (modo files).
  • CreaRack-Pro/scripts/bib_cochange_backfill.py · backfill del historial (modo pairs).

Parámetros

Entrada (args)

Modo 1 — Ingest post-merge:

{
  "files": ["monitoring/api/signage.py", "static/js/pages/signage/SignageContentManager.js"],
  "repo": "CreaRack-Pro" | "CreaRackSL-workspace"
}

Lista de archivos tocados en el merge. El handler genera todos los pares (archivo_i, archivo_j) y suma +1 al contador en D1.

Modo 2 — Backfill del historial:

{
  "pairs": [
    ["monitoring/api/signage.py", "static/js/pages/signage/SignageContentManager.js", 7],
    ["forms/device_form.py", "templates/device_form.html", 5]
  ],
  "repo": "CreaRack-Pro"
}

Pares pre-agregados como [file_a, file_b, count]. El handler suma += count en D1 para cada par.

Salida

{
  "recorded_pairs": 42,
  "repo": "CreaRack-Pro"
}

O con degradación:

{
  "recorded_pairs": 0,
  "skipped": "menos de 2 archivos",
  "repo": "CreaRackSL-workspace"
}

Lógica interna

1. Normalización de pares

  • Deduplica archivos idénticos en modo files.
  • Ordena lexicográficamente: file_a < file_b (garantiza una sola fila por pareja en BD).
  • Ignora autolazos (a !== b).

2. Anti-ruido

CriterioLímiteRazón
MAX_FILES25 archivosRefactors masivos generarían N·(N-1)/2 pares basura; umbral conservador.
MAX_PAIRS1000 pares por llamadaProtege subrequest budget en D1; el backfill trocea en múltiples llamadas.
Extensiones code.py, .ts, .tsx, .js, .jsx, .mjs, .cjs, .astro, .vue, .html, .css, .scss, .sqlExcluye .md, CHANGELOG, públicos.
Exclusiones.github/scripts/bib_*, scripts/harness/, public/supercontext/, src/content/wiki/Scripts de infraestructura, generados, wikis (churn ruido).
Denylistconfig/settings/base.pyCambia en CADA release por versionado; haría que co-cambie con todo → invalida la señal.

Ejemplo: merge de 3 archivos .py → genera 3 pares (C(3,2) = 3). Merge de 30 archivos → se rechaza (>MAX_FILES); error devuelto.

3. UPSERT en batch

INSERT INTO bib_cochanges (repo, file_a, file_b, count, last_seen)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(repo, file_a, file_b)
DO UPDATE SET count = count + excluded.count, last_seen = datetime('now')
  • Un solo round-trip a D1 (batch de N statements).
  • count es aditivo: primer merge suma +1; posteriores suman lo que corresponda.
  • last_seen siempre se actualiza al ahora.

Implementación (functions/api/mcp/handlers/biblioteca.ts)

Case bib_record_cochanges en el switch de handlers:

  1. Parse args: extrae files (array de strings) O pairs (array de triples [a, b, count]).
  2. Modo files:
    • Filtra por extensiones y exclusiones (mismo código que backfill).
    • Verifica 2 ≤ count ≤ MAX_FILES.
    • Genera todos los pares (i, j con i < j).
  3. Modo pairs:
    • Valida estructura de triples.
    • Ordena lexicográficamente.
    • Filtra pares válidos (a !== b, ambos non-empty).
  4. Chequeos globales:
    • Si pares es 0 → devuelve skip.
    • Si pares > MAX_PAIRS → error (troceador debe hacerlo).
  5. Batch UPSERT: prepara statements, ejecuta en un solo db.batch().
  6. Respuesta: {recorded_pairs: N, repo} o error.

Control de acceso (gate readonly)

“Interno” arriba se refiere solo a que no figura en el catálogo público tools/list — pero el dispatcher MCP no bloquea por eso: bloquea por nombre, contra las listas LOGGED_TOOLS / AI_COST_TOOLS / UNLOGGED_WRITE_TOOLS de functions/api/mcp/index.ts. Hasta el 10-09-2026 este handler no figuraba en ninguna de las tres, así que un token marcado readonly (MCP_READONLY_TOKENS) podía invocarlo igual y escribir en bib_cochanges — el nombre del handler no es secreto (está en este mismo documento y en el código).

Lo detectó el verificador en Opus de la regeneración del Atlas de arquitectura (ficha ws2) y se corrigió en el commit b7bfcf74 (#169): bib_record_cochanges entra en UNLOGGED_WRITE_TOOLS, así que ahora un token readonly recibe error al intentar invocarlo. El mismo commit añadió un test de aptitud en test/mcp-dispatcher.test.ts que recorre el catálogo tools/list y exige que toda tool con verbo de escritura en el nombre pase el gate, para que este tipo de hueco no vuelva a pasar desapercibido.


Degración elegante en bib_impact_query

El enriquecimiento que consume estos datos:

const cochangedFiles: ... = [];
if (args.file_path) {
  try {
    const coRows = await db.prepare(`SELECT ... FROM bib_cochanges WHERE ...`).all();
    cochangedFiles = coRows.map(...);
  } catch {
    cochangedFiles = [];  // Si tabla no existe: devolver [], NO error
  }
}
return JSON.stringify({ ..., cochanged_files: cochangedFiles });

Si la migración 0043 no se aplicó aún, el error es atrapado y se devuelve lista vacía. La tool CORE nunca se rompe.


Invocaciones

Desde ingest (.github/scripts/bib_ingest.py)

def record_cochanges(changed_files: list[str], mcp_token: str, repo_name: str) -> dict:
    code_paths = [f for f in changed_files if ...]  # Filtra código
    if len(code_paths) < 2:
        return {"recorded_pairs": 0, "skipped": "..."}
    repo = "CreaRackSL-workspace" if "workspace" in repo_name.lower() else "CreaRack-Pro"
    raw = call_mcp("bib_record_cochanges", {"files": code_paths, "repo": repo}, mcp_token)
    result = json.loads(raw) if raw else {}
    return result
  • Se llama incondicional después de cada merge (NO depende de si el ingest procesa la PR).
  • Idempotente: reintentos son seguros.
  • Tolerante: si MCP falla, se registra error pero ingest sigue.

Desde backfill (CreaRack-Pro/scripts/bib_cochange_backfill.py)

# Pseudocódigo
for commit in git.log():
    files = commit.touched_files
    code_files = [f for f in files if ...]
    pairs = [(code_files[i], code_files[j], 1) for i < j in code_files]
    # Agrupa pares idénticos:
    aggregated = defaultdict(int)
    for a, b, c in pairs:
        key = (min(a,b), max(a,b))
        aggregated[key] += c
    # Trocea en lotes de MAX_PAIRS:
    for batch in chunk(aggregated.items(), MAX_PAIRS):
        call_mcp("bib_record_cochanges", {"pairs": batch, "repo": repo})

Datos internos

Fuente de datos:

  • Tabla bib_cochanges (D1).
  • Campos: repo, file_a, file_b, count, last_seen.

Índices:

  • idx_bib_cochanges_file_a (búsqueda por file_a).
  • idx_bib_cochanges_file_b (búsqueda por file_b).

Ciclo de vida:

  • Backfill one-shot (opcional) popula tabla del histórico.
  • Post-merge ingest suma +1 a los pares del merge actual.
  • Consulta desde bib_impact_query con parámetro cochange_min (default 2).

Error handling

EscenarioRespuesta
files vacío o <2 archivos{recorded_pairs: 0, skipped: "..."}
files >25 archivos{recorded_pairs: 0, skipped: ">25 archivos (ruido)", files: N, repo}
pairs genera >MAX_PAIRS{error: "Demasiados pares..."}
Input inválido (ni files ni pairs){error: "Falta filesopairs"}
BD falla (tabla no existe, conexión perdida){recorded_pairs: 0, error: "..."}

Ninguno de estos errores rompe al caller (ingest o backfill); se registran en logs y se ignora (es ok perder un merge de co-cambios).


Véase también

  • [[feature—biblioteca—cairn-cochanges]]
  • [[entity—functions—tool—bib-impact-query]]
  • [[entity—functions—database—bib-cochanges]]
  • [[concept—biblioteca—grafo-estatico-vs-empirico]]
  • [[entity—functions—tool—call-mcp]]