Ir al contenido
CodArchel mapa vivo de la arquitectura
ESEN

Modelo de datos

Estado: M4 implementado. packages/schema valida cc.json 2.x, normaliza 1.x y comparte la identidad canónica; packages/parsers produce snapshots 2.0 con métricas, grafo y lente de migración.

Si el modelo está bien, el resto es barato. Esta es la pieza que hay que discutir antes de escribir código, porque cambiarla después contamina el CLI, el mapa, el bot y el MCP a la vez.


identidad árbol de ficheros y carpetas, con ids estables
señales lentes aditivas: métricas, dependencias, dominio, migración…
grafo aristas tipadas entre ids, no dentro del árbol

La jerarquía de carpetas es una proyección del grafo, no al revés. Un módulo puede vivir en src/payments y depender de src/infra, y esa relación no cabe en un árbol.

La forma es deliberadamente la de cc.json 2.x: { meta, files, lenses }. Adoptarla nos da interoperabilidad gratis y nos obliga a una separación que ya estábamos buscando.

type Snapshot = {
meta: {
projectName: string
apiVersion: "2.0"
checksum: string // md5 canónico de files + lenses, sin historial
commitHash?: string
}
files: FileNode[] // raíz única (normalmente una carpeta)
lenses: {
metrics: MetricsLens
dependency: DependencyLens
[name: string]: unknown // lentes futuras se preservan
}
}

Un snapshot es inmutable. Analizar dos commits produce dos snapshots; el diff es una consulta entre ambos, nunca un tercer formato.

type FileNode = {
id: string // sha256("File/src/App.ts")[:16]
name: string
type: 'File' | 'Folder'
contentHash?: string
children?: FileNode[]
}

Reglas:

  • El id depende solo del tipo y la ruta canónica. Renombrar cambia la identidad; mover, también.
  • Rutas normalizadas con /, sin rutas absolutas ni .., excluyendo la raíz del proyecto.
  • El id de carpeta usa la misma regla con prefijo Folder/.
  • Nada de mtime, usuario ni hora en el payload.
  • packages/schema/src/identity.ts es la única implementación de esta regla.

El importador acepta el formato antiguo y lo convierte a este modelo antes de validarlo:

  • Nodos y attributes → files + lenses.metrics.attributes, con ids generados por la misma regla (sha256(type + ruta canónica)[:16], raíz excluida).
  • edges[].fromNodeName / toNodeName → ids de nodo; se aceptan rutas con o sin raíz y con o sin / inicial.
  • attributeTypes y attributeDescriptors se reparten entre métricas y dependencias.
  • blacklist, markedPackages y fixedPosition son estado de vista: se descartan.
  • Varias raíces 1.x se envuelven en una raíz sintética para cumplir el contrato 2.x.

El checksum 1.x no se reutiliza: cubre otro payload y daría una garantía falsa.

Cada lente es un mapa id → datos. Desconocida = se preserva tal cual al reexportar.

LenteContenidov1
metricsatributos numéricos por nodo + descriptoressí
dependencyaristas, niveles, ciclos, capassí
riskscore compuesto, violacionesderivada en render, no se almacena
migrationLOC por lenguaje, estado, deuda y frontera FFIsí (v1)
symbolslo que hay dentro de cada fichero: funciones, clases, tipos y variables del módulosí (M11)
structuremiembros de clase e imports/exports del ficherosí (M44)
duplicationlíneas compartidas entre ficheros y dentro de uno, y pares copy/divergingsí (M68)
domainpalabras, frecuencia, tf-idfdespués
evolutionseries temporales, acoplamiento temporaldespués
type AttributeDescriptor = {
title?: string
description?: string
direction?: -1 | 1 // -1: menor es mejor; 1: mayor es mejor; 0/ausente: neutral
hintLowValue?: string
hintHighValue?: string
}

direction no es decoración: permite que el mismo motor pinte “rojo = malo” sin que la UI tenga que saber qué es la complejidad ciclomática.

Métricas actuales del analizador:

GrupoMétricas
Parserloc, rloc, functions, maxComplexity, maxFunctionLength
Importsimports, externalImports, builtinImports, typeOnlyImports, unresolvedImports
GrafofanIn, fanOut (todos los imports), inCycle, cycleSize, dependencyLevel (ejecución)
Historialnumber_of_commits, number_of_authors
MigraciónmigrationDebt, ffiCrossings
DuplicaciónduplicatedLines, repeatedLines

Todas se emiten como absolute. Las métricas se agregan por fichero; los símbolos quedan para una lente posterior. Las dos métricas de historial solo aparecen si git está disponible y el análisis no se ejecuta con --no-git.

functions y maxComplexity cuentan todas las funciones implementadas, también las anónimas y las anidadas. La lente de símbolos lista solo las declaraciones con nombre; un callback no aparece en el inspector. Las dos cifras no tienen por qué coincidir (ADR 0040).

rloc cuenta las líneas con al menos un token de código. Los comentarios se detectan por su texto, no por el tipo de nodo del árbol: dentro de un interface, el binding nativo de tree-sitter tipa el comentario como interface_body y el WASM como comment. Con la regla por texto, el CLI y el navegador cuentan las mismas líneas (ADR 0020).

type Edge = {
fromId: string
toId: string
attributes?: {
weight: number // imports entre los dos ficheros
typeOnly: number // cuántos son solo tipos
}
isCyclic?: boolean
}

El snapshot actual no guarda kind por arista: agrega peso y type-only. El parser sí distingue static, export, dynamic, require y import x = require(); esa información se usa para clasificar y podría añadirse como atributo si el diff la necesita.

isCyclic se deriva del grafo de ejecución y se cachea en el snapshot como optimización; siempre se puede recalcular. Un componente cíclico es un SCC de tamaño > 1 o un self-loop de ese grafo. Una arista cuenta si weight > typeOnly: los imports que solo son de tipos siguen en la lente de dependencias y en fanIn/fanOut, pero no cierran un ciclo ni suben dependencyLevel (ADR 0037).

Es nuestra lente propia, y nace de un caso real: este proyecto migrará de TypeScript a Rust. La v1 está documentada en ADR 0016.

type MigrationNode = {
language: 'ts' | 'js' | 'rust' | 'wasm' | 'node'
status: 'js' | 'rust' | 'bridge' | 'native'
loc: number
rloc: number
debt?: number // solo TS/JS: (maxComplexity + 1) × (1 + fanIn)
crossings: number // aristas FFI wasm/node incidentes
}

La lente guarda nodes, edges (kind: 'wasm' | 'native') y un summary con reparto por lenguaje, conteo por estado, puentes, aristas FFI y deuda total.

Permite responder preguntas que ninguna herramienta actual responde bien:

  • ¿Cuánto queda por migrar, ponderado por complejidad y acoplamiento, no por líneas?
  • ¿Dónde está la frontera napi/WASM y cuántos cruces hay?
  • ¿La migración está reduciendo el acoplamiento o solo moviéndolo?
  • ¿Qué módulos migrados siguen siendo llamados desde JS y a qué coste?

Límites de v1: Rust solo se mide con un escáner léxico, no se parsean imports ni complejidad; las anotaciones #[napi]/#[wasm_bindgen] marcan el fichero como bridge, pero no generan aristas porque el glue no está en el fuente. Las aristas FFI solo existen cuando un import TS/JS resuelve a un .wasm o .node incluido en el análisis.

Si la herramienta no sirve para gestionar su propia migración, no sirve.

La escala de lo que está escrito dos veces. Nace de este repo: la app web y el escritorio comparten main.tsx carácter a carácter, y los barrels de schema y lint repiten buena parte de sus módulos (ADR 0102).

type DuplicationNode = {
duplicatedLines: number // líneas en bloques de 8 compartidos con otro fichero
repeatedLines: number // líneas en bloques de 8 repetidos dentro del fichero
duplicateOf?: string // el fichero del que es copia literal, si lo hay
}
type DuplicatePair = {
fromId: string
toId: string
lines: number // líneas del bloque compartido, sin contar dos veces
similarity: number // 0..100: ventanas compartidas / las del fichero menor
status: 'copy' | 'diverging'
}

El detector no sabe de gramáticas: toma las líneas significativas que ya cuenta rloc, las normaliza —sin espacios, sin líneas vacías, sin cierres sueltos— y busca ventanas de 8 líneas por SHA-256. Dos ficheros con la misma ventana comparten bloque; las ventanas contiguas se funden antes de contar, así que lines es la unión de rangos. Un fichero con menos de ocho líneas significativas no genera ventana.

similarity = ventanas compartidas / min(ventanas de A, ventanas de B). copy es 100; diverging va de 40 a 99, la copia que se ha separado; por debajo de 40 no hay par, para no llenar el snapshot de coincidencias de azar. Un renombrado o un reformateo no se detecta en v1: pediría AST o normalizar identificadores y rompería la paridad nativo/WASM.

La proporción no es una métrica del snapshot: se calcula al leer (duplicatedLines / rloc). Carpetas y proyecto suman líneas y dividen por su rloc; los porcentajes no se suman nunca. La duplicación no se suma a migrationDebt: una copia no cuesta migrarla dos veces y el porcentaje no es aditivo.

La tercera escala del modelo: qué hay dentro de cada fichero. Hasta M11 el mapa solo knew cuántos ficheros había y cómo se llamaban entre sí; un fichero de dos mil líneas y veinte ficheros de cien eran el mismo mapa.

type CcSymbol = {
name: string
kind: 'function' | 'class' | 'interface' | 'type' | 'enum' | 'variable'
line: number // 1-based
lines?: number // líneas que ocupa
complexity?: number // complejidad ciclomática; solo en las funciones con cuerpo
}

La lente guarda nodes (los símbolos de cada fichero, por su id) y un summary con el total, el reparto por tipo y la función más compleja del repo. Todos los símbolos guardan su rango de líneas, no solo las funciones (ADR 0067).

Decisiones (ADR 0025):

  • El parser la recoge en el mismo paseo que mide las métricas: no cuesta un segundo análisis ✓.
  • El código es compartido con el navegador ✓, así que el checksum del snapshot no cambia y la paridad navegador/CLI sigue valiendo ✓.
  • Todas las declaraciones con nombre del módulo: funciones, clases, interfaces, tipos, enums y variables (const, let o var con valor), también las constantes de texto y los objetos de configuración. Las variables locales no: son implementación y llenarían la lista de ruido (ADR 0063) ✓.
  • El diff sigue siendo de ficheros. Comparar símbolo a símbolo es un paso aparte (M12) ✓. Un símbolo cuenta como cambiado si cambia su tipo, su complejidad, sus líneas o el hash de su cuerpo ✓.

Un fichero también contiene cosas que no son declaraciones del módulo: los campos de una clase, lo que importa y lo que exporta. La lente structure las guarda por fichero, sin darles identidad de símbolo (ADR 0064 ✓):

type CcStructureNode = {
members: Array<{ owner: string; name: string; line: number; from: 'field' | 'parameter' }>
imports: Array<{ source: string; names: string[]; kind: 'static' | 'dynamic' | 'require'; typeOnly: boolean }>
exports: Array<{ name: string; local?: string; source?: string; kind: 'declaration' | 'default' | 'named' | 'all' | 'namespace' }>
}
  • Miembros: campos (static, private, readonly incluidos) y propiedades del constructor, agrupados por su clase. No tienen hash ni cadena: la clase ya es el símbolo.
  • Superficie: imports con default, * as ns, alias y type; exports con declaraciones, default, *, * as ns y el origen de una reexportación.
  • La ficha los enseña en dos secciones plegables, Superficie y Miembros, también cuando el fichero no tiene ningún símbolo. Un barrel deja de ser una ficha vacía.
  • Quedan fuera las claves de objetos de configuración, los espacios de nombres y el código suelto de nivel superior: son datos o sentencias, no estructura del fichero ✓.

La lente es aditiva: un snapshot anterior se lee como si no tuviera estructura y el checksum cambia porque hay contenido nuevo, no porque cambie la regla de identidad.

Las reglas viven en .codarch.yml y se evalúan al hacer diff, no al analizar. El snapshot dice cómo es; las reglas dicen cómo debería ser. Mezclarlos impediría comparar el mismo commit contra dos políticas distintas.

version: 1
layers:
- name: domain
paths: [src/domain/**]
- name: infra
paths: [src/infra/**]
rules:
- name: domain-no-infra
type: forbidden
from: domain
to: infra
severity: error
- name: complejidad
type: threshold
metric: maxComplexity
max: 40
severity: warn
- name: sin-ciclos
type: cycles
severity: error

El reporte de diff añade:

type RuleReport = {
configured: boolean
summary: {
layers, rules,
introduced, existing, resolved,
introducedErrors, introducedWarnings
}
violations: {
introduced: RuleViolation[]
existing: RuleViolation[]
resolved: RuleViolation[]
}
}
type RuleViolation = {
key: string // estable entre base y head
rule: string
type: 'forbidden' | 'threshold' | 'cycles'
severity: 'info' | 'warn' | 'error'
message: string
files: string[] // solo para mostrar
edge?: { fromId, toId, from, to }
metric?: { name, value, min?, max? }
cycle?: { size, nodes }
}

Solo las violaciones introducidas con severidad error bloquean con --fail-on violations. La deuda existente se publica, pero no impide mergear. Ver rules.md y ADR 0011.

packages/metrics implementa diffSnapshots(base, head, { rules }). No guarda el delta: lo calcula entre dos snapshots inmutables. rules es opcional; si falta, rules.configured es false.

type DiffReport = {
schemaVersion: '1.6'
base: { projectName, checksum, files, folders, edges }
head: { projectName, checksum, files, folders, edges }
summary: {
addedFiles, removedFiles, changedFiles,
addedEdges, removedEdges,
cyclicEdgesAdded, cyclicEdgesRemoved,
introducedCycles, fixedCycles, hotspots
}
totals: {
metrics: Record<string, MetricTotal> // base, head y delta por métrica
} | null
rules: RuleReport
files: {
added: DiffFile[] // id, path, metrics
removed: DiffFile[]
changed: Array<{ id, path, changes: MetricChange[] }>
}
edges: {
added: EdgeChange[]
removed: EdgeChange[]
cyclicAdded: EdgeChange[]
cyclicRemoved: EdgeChange[]
}
cycles: {
introduced: CycleComponent[] // SCCs de tamaño > 1 o self-loops
fixed: CycleComponent[]
}
hotspots: Hotspot[]
hotspotMetric: string | null
migration: MigrationReport | null // la migración, que es el motivo de ser
symbols: DiffSymbolReport | null // qué pasó dentro de los ficheros
duplication: DuplicationReport | null // el código copiado, entre quiénes
}

Reglas:

  • Identidad por id; las rutas son solo para mostrar. Un rename es alta + baja.
  • MetricChange.delta es null si la métrica solo existe en un lado.
  • Aristas por (fromId, toId), con peso y type-only agregados.
  • Ciclos por SCC, comparando el conjunto de ids del componente. Un cambio de tamaño cuenta como un ciclo resuelto y otro introducido.
  • Hotspots por maxComplexity (o rloc si falta), solo con delta positivo; un fichero nuevo parte de 0. Sin fórmula compuesta opaca.
  • La sección migration compara la lente de migración de la base con la del head: el estado por lenguaje y categoría, la deuda, los cruces FFI, los ficheros cuya deuda cambia y los de más deuda. Sus deltas son head - base como en el resto del reporte: en la deuda, un delta negativo es bueno.
  • La sección symbols compara la lente de símbolos de la base con la del head: por fichero, los símbolos que se añadieron, se quitaron o cambiaron, con su complejidad antes y después. Un cambio de cuerpo también cuenta, aunque la complejidad y las líneas no cambien. Empareja por nombre dentro del mismo fichero; reconocer renombrados y mudanzas es trabajo de la cadena (ADR 0026).
  • Cada símbolo lleva addedLines y removedLines, las líneas que suman y restan. No es el delta de rloc del fichero: StartFn puede sumar 10 líneas y quitar 4 dentro de un fichero que crece 11 en neto. No salen de los snapshots, que no guardan código: las mide quien tiene las dos fuentes a mano —la comparación HEAD, con la base exportada y el árbol— y las inyecta como source. Desde dos cc.json sueltos quedan en null, que es «no medido», no un cero.
  • totals guarda el total de cada métrica en la base y en el head, con su delta: suma las métricas aditivas y toma el máximo de las que ya son un máximo (maxComplexity, maxFunctionLength…). Una métrica que un lado no declara sale null, y entonces el delta también. La pestaña Cambios pinta la tabla con esos totales; sin la sección, no la pinta.
  • La sección duplication compara la lente de duplicación de la base con la del head: las líneas duplicadas y repetidas, los pares que aparecen, los que dejan de compartir y los que se separan, con su similitud antes y después. Los pares se cruzan por sus dos rutas, no por id: un par sigue siendo el mismo si los ficheros siguen siendo los mismos. Con la lente en un solo lado no hay comparación: lines.delta es null y el reporte dice qué lado está sin medir.
  • schemaVersion sube cuando cambia la forma del reporte. Los lectores reconstruyen los reportes de versiones anteriores a la actual, así que siempre se usa la más nueva: lo que no se pudo medir (la sección de migración en un 1.0, la duplicación antes de la 1.5, los totales antes de la 1.6) se marca con null en vez de inventar ceros.
  • Las violaciones se comparan por clave estable entre base y head; solo las introducidas con severidad error bloquean con --fail-on violations.
  • report.json es determinista y no tiene timestamps. Ver ADR 0010 y ADR 0011.
walkSourceFiles(root)
→ .gitignore raíz + anidados, `ignore` del .codarch.yml, las extensiones de cada parser
+ Rust/WASM/Node, orden estable
parserFor(parsers, path)
→ el parser de la extensión (TS/JS, Java, Python, Go, C#); la caché se firma con su nombre
parser.parse(source, path)
→ loc/rloc/functions/complejidad + imports + símbolos y miembros
parser.importTargets(import, path) · ImportResolver(root, files)
→ a qué apunta el import de cada lenguaje; el `using` de C# se cierra al final del análisis
measureRustSource(source)
→ loc/rloc léxico, sin imports ni complejidad
collectGitChurn(root, files)
→ number_of_commits, number_of_authors (un solo git log)
buildGraph(files, links)
→ dedupe, fan-in/out (todos), SCC de ejecución, ciclos y niveles
buildMigration(files, ffiLinks)
→ estados, deuda, cruces y resumen por lenguaje
buildSnapshot({ projectName, files, graph, migration })
→ árbol, lentes metrics/dependency/migration, checksum
diffSnapshots(base, head, { rules })
→ ficheros/aristas/ciclos/hotspots + violaciones introducidas/existentes/resueltas
loadArchitectureStore(snapshot, report)
→ índice de solo lectura, riesgo compartido, SCC, agregados de carpeta
MCP tools
→ mapa, hotspots, impacto, violaciones y explicación; sin reanalizar

Cada fase es pura o casi pura y tiene tests propios. El analizador no guarda árboles de tree-sitter: procesa un fichero, extrae agregados y sigue.

El alcance no se adivina: además de .gitignore, codarch analyze, codarch serve y el análisis en navegador leen la lista ignore del .codarch.yml de la raíz (ADR 0038). Un fichero excluido no llega al pipeline, así que tampoco aparece en el snapshot, las métricas, los ciclos ni el checksum. --ignore suma patrones para una ejecución y --no-config desactiva la lectura del archivo; diff, render e inspect no vuelven a filtrar porque ya consumen el snapshot.

Para que el checksum signifique algo:

  1. Serialización canónica: claves ordenadas, sin espacios, UTF-8.
  2. Nodos ordenados por ruta; aristas por (from, to); SCCs y ciclos ordenados.
  3. Sin timestamps, rutas absolutas, nombres de usuario ni locale.
  4. Números con representación estable (nada de 1.0 vs 1).
  5. Orden de recorrido del filesystem irrelevante por construcción.
  6. Churn derivado del historial del commit, no de la hora de ejecución; sin fechas ni autores en el payload, y fuera del checksum.

El checksum actual es MD5 de { files, lenses } serializado canónicamente, con una excepción: la lente de métricas entra sin number_of_commits ni number_of_authors, ni sus descriptores ni sus tipos. La identidad es el código, así que el CLI con git, --no-git, el navegador y cualquier clon dan el mismo número (ADR 0103). El historial sigue en el snapshot: cuando se mide, meta.historyChecksum hashea sus valores para distinguir «mismo código, otro calor» de «otro código». Un snapshot sin historial no tiene ese campo: está sin medir, no a cero.

MD5 es suficiente para detectar cambios de payload; si más adelante se usa como identificador de caché entre máquinas, se puede migrar a SHA-256 sin cambiar el contrato.

Test de conformidad: analizar el mismo commit dos veces produce el mismo snapshot y el mismo checksum, y con git o sin git da el mismo. Está en packages/parsers/test/analyze.test.ts y packages/parsers/test/git.test.ts.

  1. ¿Símbolos o solo ficheros? La v1 agrega complejidad por fichero (maxComplexity, functions) sin modelar clases y funciones como nodos. Modelarlas da hotspots más precisos, pero multiplica el tamaño del grafo y el coste de los parsers.
  2. ¿Aristas a nivel de símbolo? Igual que arriba: leafEdges de cc.json 2.x existe, pero es otro orden de magnitud.
  3. ¿Dónde vive la caché? Un .codarch/cache con hashes de contenido permite análisis incremental. ¿Ficheros JSON o SQLite?
  4. ¿El reporte es el snapshot? Propongo que no: snapshot = datos; reporte = hallazgos derivados, con su propia versión.
  5. ¿Churn dentro del snapshot o derivado? Decidido en M2: dentro del snapshot como number_of_commits y number_of_authors, para que el diff sea autocontenido. Un shallow clone produce un warning; no hay ventana temporal relativa. Desde el ADR 0103 no entra en el checksum: el calor tiene su propio meta.historyChecksum.