La documentación caducada no presenta síntomas. El archivo abre, el marcado es válido, la fecha de la cabecera resulta verosímil, los enlaces llevan adonde llevaban. Lo único que no contiene es la verdad sobre el código actual. Hace un mes alguien sacó tres funciones a un módulo aparte, renombró el punto de entrada y cambió el orden de los argumentos. AGENTS.md no se enteró, porque no tenía con qué enterarse.
Para una persona esto es una molestia: lees, tropiezas y te vas a leer el código. Para un agente es otra cosa. El modelo no percibe ese «aquí algo no cuadra»: acepta el archivo de contexto como un hecho y ejecuta con idéntica seguridad tanto la instrucción recién escrita como la que describe una arquitectura de hace seis meses. Después el agente escribe código siguiendo una descripción obsoleta, hace commit y se marcha. En ClewWiki —una base de conocimiento self-hosted donde las páginas las escriben conjuntamente personas y agentes de programación, AGPL-3.0, todavía en pre-alpha y sin releases— de este problema nació el mecanismo de code anchors.
Por qué «archivo y líneas» no es un ancla#
La solución obvia sería esta: anotar junto a la página src/anchors/resolve.ts:127-184 y comprobar si esas líneas han cambiado. No funciona, y se rompe más rápido de lo que parece.
Un rango de líneas no sobrevive a una edición por encima de sí mismo. Alguien añade un import al principio del archivo, todo el código baja una línea y el rango ya apunta a la declaración vecina. Una pasada del formateador da la vuelta a casi todos los rangos del repositorio de golpe. Renombrar una función, algo que no cambia absolutamente nada del comportamiento, también cuenta como cambio. Resultado: un torrente de avisos entre los que apenas hay unos pocos reales, y a las dos semanas el equipo deja de leerlos.
Esa cifra la medimos, no la estimamos a ojo. Sobre un historial real de refactorizaciones —26 commits, 479 anclas— las anclas ingenuas por line range dieron cerca de un 50 % de falsos positivos. Las anclas simbólicas, sobre ese mismo historial, dieron cero.
De ahí la primera decisión: la identidad del ancla es el par {kind, qualified_name}, no la ubicación. La función languageForPath sigue siendo ella misma aunque se mude a otro archivo. El ancla tiene un campo file_hint, pero solo sirve para empezar la búsqueda por el sitio correcto, no para determinar qué se busca.
Aun así, los line ranges se quedaron, como un fallback honesto para bloques sin declaración resoluble: un archivo de configuración, una tabla de constantes, un fragmento de prosa en el README. Para ellos se normalizan la indentación y las líneas en blanco, y cada respuesta de check incluye fallback_share, la proporción de anclas de ese tipo en el space. Una proporción creciente significa que el espacio se está deslizando poco a poco hacia el camino que ya sabemos que se rompe; es un indicador temprano de ruido, no un adorno de la respuesta.
Hash de tokens, no hash de texto#
La segunda decisión es qué se hashea exactamente. El hash del texto de la declaración es inútil por la misma razón por la que lo es el rango de líneas: reacciona a todo. Por eso el ancla guarda el hash de la secuencia de tokens que produce el parser, descartando los comentarios.
La diferencia práctica: reindentar una función, repartir los argumentos en varias líneas, reescribir un comentario de documentación, cambiar comillas simples por dobles; nada de eso altera el hash. Un cambio en lo que el código hace sí lo altera, por construcción. Ese es justo el comportamiento que esperas de una señal que dice «la documentación sobre esto ha caducado».
Los parsers son tree-sitter compilado a WebAssembly. No es una pose: los bindings nativos meterían node-gyp en la imagen de runtime y exigirían recompilar con cada actualización de Node, y todo por un parser que solo lee. La build WASM se coloca en la imagen como datos y sobrevive al upgrade del runtime. Las tablas de declaraciones cubren hoy Swift, TypeScript y TSX (.swift, .ts, .mts, .cts, .tsx); Kotlin es el siguiente.
Una escalera de cinco etapas y cuatro estados#
Comprobar un ancla es recorrer una escalera ordenada, y es precisamente ese orden el que separa una refactorización de un problema en la documentación:
| Etapa | Qué se busca | Resultado |
|---|---|---|
| 1 | mismo archivo, misma identidad | fresh o stale |
| 2 | otro archivo, misma identidad | moved |
| 3 | mismo cuerpo con otro nombre en el mismo contenedor | renamed |
| 4 | mismo cuerpo en cualquier sitio | moved and renamed |
| 5 | nada | lost |
Las etapas 3 y 4 hacen match por el hash del cuerpo, no de la declaración completa. La razón es mecánica: el hash completo incluye el nombre, y renombrar lo cambia por definición; es decir, con el hash completo resulta imposible detectar un renombrado, por principio.
El hash del cuerpo tiene su propia trampa: los cuerpos cortos coinciden por casualidad. { return nil } aparece veinte veces en un archivo, y cualquiera de esas ocurrencias «demostrará» que la función se renombró justo a esa. Por eso un cuerpo más corto que MIN_BODY_TOKENS = 8 tokens no hace match en absoluto. Una respuesta equivocada con aplomo es peor que un lost honesto.
El ancla tiene cuatro estados. fresh: la declaración sigue ahí y no ha cambiado. stale: la misma declaración con el cuerpo modificado. moved-renamed: la hemos encontrado, y la respuesta dice dónde está ahora y cómo se llama ahora. lost: no se resuelve nada; esto no es «se ha roto», sino una petición de decisión a una persona: borrar el ancla, revincularla o admitir que la página describe código que ya no existe.
Tres anclas, un commit#
El mecanismo lo verifiqué a mano en una instancia local. Tres anclas de tipo symbol sobre funciones reales del paquete: resolveAnchor, languageForPath, lineRangeAnchor. Primera comprobación: fresh, fresh, fresh. Después, un commit que añade una sola línea al cuerpo de languageForPath, y una segunda comprobación:
{
"complete": true,
"budget": { "files_read": 3, "bytes_read": 10524, "elapsed_ms": 35, "limit": null },
"anchors": [
{ "qualified_name": "resolveAnchor", "state": "fresh",
"detail": { "reason": "identity_matched" } },
{ "qualified_name": "languageForPath", "state": "stale",
"detail": { "reason": "body_changed" } },
{ "qualified_name": "lineRangeAnchor", "state": "fresh",
"detail": { "reason": "identity_matched" } }
],
"fallback_share": { "total": 3, "fallback": 0, "share": 0 }
}Exactamente un ancla cambió de estado. Las dos declaraciones vecinas del mismo archivo, desplazadas por esa edición en cuanto a líneas, siguieron en fresh, porque las líneas no tienen nada que ver con la identidad. En la interfaz esto es una tarjeta: «function languageForPath — Stale — The declaration is still there, but its body changed» y un botón «Confirm reviewed».
El botón es aquí un punto de principio. El aviso solo lo retira un confirm explícito (POST /api/v1/anchors/{id}/confirm), un acto consciente y auditable de una persona o un agente. Una insignia que desapareciera por sí sola devaluaría el silencio de todas las demás: no puedes fiarte de un estado verde si el verde a veces significa «nadie lo ha mirado, simplemente caducó en silencio».
El ancla se resuelve en el mismo momento de crearla: una errata en el nombre del símbolo se rechaza en el acto, en vez de aflorar como un misterioso lost una semana más tarde.
Presupuestos y lo que el sistema no hace#
Cada comprobación tiene límites estrictos: 2000 archivos, 32 MB de fuentes, 30 segundos; los archivos de más de 1 MB se omiten; las etapas repo-wide leen hasta 4000 archivos. Si se agota el presupuesto, la respuesta lo dice sin rodeos —"complete": false, budget.limit, la lista unchecked_anchor_ids— y las anclas no comprobadas conservan su estado anterior. Marcarlas como lost sería mentir sobre código que no hemos leído.
El repositorio se lee, pero nunca se ejecuta. En el space se coloca un bare mirror bajo REPOS_DIR, los blobs se extraen con git show y no se crea árbol de trabajo; por tanto, ni una build, ni un script de instalación, ni un hook pueden llegar a ejecutarse físicamente. La credencial de un repositorio privado se indica mediante el nombre de una variable de entorno, no su valor:
CLEWWIKI_GIT_TOKEN_INTERNAL=ghp_... # el valor vive solo en el entornoEn los ajustes del repositorio se guarda la cadena CLEWWIKI_GIT_TOKEN_INTERNAL, el nombre, no el secreto; solo se admiten CLEWWIKI_GIT_TOKEN y CLEWWIKI_GIT_TOKEN_<NAME>.
El token se envía únicamente al origin https de su propio repositorio y no acaba ni en la base de datos, ni en las copias de seguridad, ni en una respuesta de la API.
Ese mismo mecanismo de staleness funciona también sin código: una página puede tener un documento emparejado (technical ↔ human), y entonces el objetivo del ancla pasa a ser el content hash del par; así la versión técnica se entera de que la humana se le ha adelantado.
El proyecto está en pre-alpha: no hay tags, ni la imagen en GHCR ni el paquete npm están publicados, y la única forma de ejecutarlo es compilarlo desde el código fuente. Pero esta parte en concreto ya responde a la pregunta por la que se escribió: una página ya no puede afirmar en silencio algo falso sobre una función que ayer fue reescrita.



