Skip to content
CodArchthe living map of the architecture
ESEN

Data model

Status: M4 implemented. packages/schema validates cc.json 2.x, normalises 1.x and shares the canonical identity; packages/parsers produces 2.0 snapshots with metrics, graph and migration lens.

If the model is right, the rest is cheap. This is the piece to argue about before writing code, because changing it later contaminates the CLI, the map, the bot and the MCP at once.


identity tree of files and folders, with stable ids
signals additive lenses: metrics, dependencies, domain, migration…
graph typed edges between ids, not inside the tree

The folder hierarchy is a projection of the graph, not the other way around. A module can live in src/payments and depend on src/infra, and that relationship does not fit in a tree.

The shape is deliberately that of cc.json 2.x: { meta, files, lenses }. Adopting it gives us interoperability for free and forces a separation we were already looking for.

type Snapshot = {
meta: {
projectName: string
apiVersion: "2.0"
checksum: string // canonical md5 of files + lenses, without history
commitHash?: string
}
files: FileNode[] // single root (usually a folder)
lenses: {
metrics: MetricsLens
dependency: DependencyLens
[name: string]: unknown // future lenses are preserved
}
}

A snapshot is immutable. Analysing two commits produces two snapshots; the diff is a query between them, never a third format.

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

Rules:

  • The id depends only on the type and the canonical path. Renaming changes the identity; moving does too.
  • Paths normalised with /, no absolute paths or .., excluding the project root.
  • The folder id uses the same rule with the Folder/ prefix.
  • No mtime, user or time in the payload.
  • packages/schema/src/identity.ts is the only implementation of this rule.

The importer accepts the old format and converts it to this model before validating it:

  • Nodes and attributes → files + lenses.metrics.attributes, with ids generated by the same rule (sha256(type + canonical path)[:16], root excluded).
  • edges[].fromNodeName / toNodeName → node ids; paths with or without the root and with or without a leading / are accepted.
  • attributeTypes and attributeDescriptors are split between metrics and dependencies.
  • blacklist, markedPackages and fixedPosition are view state: they are discarded.
  • Several 1.x roots are wrapped in a synthetic root to satisfy the 2.x contract.

The 1.x checksum is not reused: it covers a different payload and would give a false guarantee.

Each lens is a map id → data. Unknown means it is preserved as-is when re-exporting.

LensContentsv1
metricsnumeric attributes per node + descriptorsyes
dependencyedges, levels, cycles, layersyes
riskcomposite score, violationsderived at render time, not stored
migrationLOC per language, status, debt and FFI boundaryyes (v1)
symbolswhat is inside each file: functions, classes, types and module variablesyes (M11)
structureclass members and the file’s imports/exportsyes (M44)
duplicationshared lines across files and within one, and copy/diverging pairsyes (M68)
domainwords, frequency, tf-idflater
evolutiontime series, temporal couplinglater
type AttributeDescriptor = {
title?: string
description?: string
direction?: -1 | 1 // -1: lower is better; 1: higher is better; 0/absent: neutral
hintLowValue?: string
hintHighValue?: string
}

direction is not decoration: it lets the same engine paint “red = bad” without the UI having to know what cyclomatic complexity is.

Current analyser metrics:

GroupMetrics
Parserloc, rloc, functions, maxComplexity, maxFunctionLength
Importsimports, externalImports, builtinImports, typeOnlyImports, unresolvedImports
GraphfanIn, fanOut (all imports), inCycle, cycleSize, dependencyLevel (runtime)
Historynumber_of_commits, number_of_authors
MigrationmigrationDebt, ffiCrossings
DuplicationduplicatedLines, repeatedLines

All of them are emitted as absolute. Metrics are aggregated per file; symbols are left for a later lens. The two history metrics only appear if git is available and the analysis is not run with --no-git.

functions and maxComplexity count all implemented functions, including anonymous and nested ones. The symbols lens only lists named declarations; a callback does not appear in the inspector. The two figures do not have to match (ADR 0040).

rloc counts lines with at least one code token. Comments are detected by their text, not by the tree node type: inside an interface, tree-sitter’s native binding types the comment as interface_body and WASM as comment. With the text rule, the CLI and the browser count the same lines (ADR 0020).

type Edge = {
fromId: string
toId: string
attributes?: {
weight: number // imports between the two files
typeOnly: number // how many are type-only
}
isCyclic?: boolean
}

The current snapshot does not store kind per edge: it aggregates weight and type-only. The parser does distinguish static, export, dynamic, require and import x = require(); that information is used to classify and could be added as an attribute if the diff needs it.

isCyclic is derived from the runtime graph and cached in the snapshot as an optimisation; it can always be recomputed. A cyclic component is an SCC larger than 1 or a self-loop of that graph. An edge counts if weight > typeOnly: type-only imports stay in the dependency lens and in fanIn/fanOut, but they do not close a cycle or raise dependencyLevel (ADR 0037).

It is our own lens, and it was born from a real case: this project will migrate from TypeScript to Rust. v1 is documented in ADR 0016.

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

The lens stores nodes, edges (kind: 'wasm' | 'native') and a summary with the split by language, the count by status, bridges, FFI edges and total debt.

It answers questions that no current tool answers well:

  • How much is left to migrate, weighted by complexity and coupling, not by lines?
  • Where is the napi/WASM boundary and how many crossings are there?
  • Is the migration reducing coupling or just moving it?
  • Which migrated modules are still called from JS, and at what cost?

v1 limits: Rust is only measured with a lexical scanner; imports and complexity are not parsed. #[napi]/#[wasm_bindgen] annotations mark the file as bridge, but they do not create edges because the glue is not in the source. FFI edges only exist when a TS/JS import resolves to a .wasm or .node file included in the analysis.

If the tool is not good at managing its own migration, it is not good.

The scale of what is written twice. It comes from this repo: the web and desktop apps share main.tsx character for character, and the schema and lint barrels repeat a good part of their modules (ADR 0102).

type DuplicationNode = {
duplicatedLines: number // lines in 8-line blocks shared with another file
repeatedLines: number // lines in 8-line blocks repeated inside the file
duplicateOf?: string // the file it is a literal copy of, if any
}
type DuplicatePair = {
fromId: string
toId: string
lines: number // lines in the shared block, not counted twice
similarity: number // 0..100: shared windows / the smaller file's windows
status: 'copy' | 'diverging'
}

The detector knows nothing about grammars: it takes the significant lines rloc already counts, normalizes them —no whitespace, no empty lines, no bare closing braces— and looks for windows of 8 lines by SHA-256. Two files with the same window share a block; adjacent windows are merged before counting, so lines is the union of ranges. A file with fewer than eight significant lines produces no window.

similarity = shared windows / min(A's windows, B's windows). copy is 100; diverging is 40 to 99, the copy that has drifted apart; below 40 there is no pair, so the snapshot is not filled with chance matches. A rename or a reformat is not detected in v1: it would need an AST or normalizing identifiers, and that would break native/WASM parity.

The ratio is not a snapshot metric: it is computed on read (duplicatedLines / rloc). Folders and the project add up lines and divide by their rloc; percentages are never added. Duplication is not added to migrationDebt: a copy does not cost migrating twice, and the percentage is not additive.

The third scale of the model: what is inside each file. Until M11 the map only knew how many files there were and how they called each other; a two-thousand-line file and twenty hundred-line files were the same map.

type CcSymbol = {
name: string
kind: 'function' | 'class' | 'interface' | 'type' | 'enum' | 'variable'
line: number // 1-based
lines?: number // lines it spans
complexity?: number // cyclomatic complexity; only on functions with a body
}

The lens stores nodes (the symbols of each file, by its id) and a summary with the total, the split by type and the most complex function in the repo. Every symbol keeps its line span, not only functions (ADR 0067).

Decisions (ADR 0025):

  • The parser collects it in the same walk that measures the metrics: it costs no second pass ✓.
  • The code is shared with the browser ✓, so the snapshot checksum does not change and the browser/CLI parity still holds ✓.
  • Every named declaration in the module: functions, classes, interfaces, types, enums and variables (const, let or var with a value), including string constants and configuration objects. Local variables are not: they are implementation and would flood the list (ADR 0063) ✓.
  • The diff is still about files. Comparing symbol by symbol is a separate step (M12) ✓. A symbol counts as changed when its kind, complexity, lines or body hash changes ✓.

A file also contains things that are not module declarations: a class’s fields, what it imports and what it exports. The structure lens stores them per file without giving them symbol identity (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' }>
}
  • Members: fields (including static, private and readonly) and constructor properties, grouped by their class. They have no hash or chain: the class is already the symbol.
  • Surface: imports with default, * as ns, aliases and type; exports with declarations, default, *, * as ns and the source of a re-export.
  • The card shows them in two collapsible sections, Surface and Members, even when the file has no symbols. A barrel is no longer an empty card.
  • Left out: object configuration keys, namespaces and top-level loose code: they are data or statements, not the file’s structure ✓.

The lens is additive: an older snapshot is read as having no structure, and the checksum changes because there is new content, not because the identity rule changed.

Rules live in .codarch.yml and are evaluated when diffing, not when analysing. The snapshot says how it is; the rules say how it should be. Mixing them would prevent comparing the same commit against two different policies.

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: complexity
type: threshold
metric: maxComplexity
max: 40
severity: warn
- name: no-cycles
type: cycles
severity: error

The diff report adds:

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

Only introduced violations with error severity block with --fail-on violations. Existing debt is published, but it does not prevent merging. See rules.md and ADR 0011.

packages/metrics implements diffSnapshots(base, head, { rules }). It does not store the delta: it computes it between two immutable snapshots. rules is optional; if missing, rules.configured is 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 and delta per metric
} | 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 larger than 1 or self-loops
fixed: CycleComponent[]
}
hotspots: Hotspot[]
hotspotMetric: string | null
migration: MigrationReport | null // migration, which is the reason for being
symbols: DiffSymbolReport | null // what happened inside the files
duplication: DuplicationReport | null // copied code, and between whom
}

Rules:

  • Identity by id; paths are only for display. A rename is an addition + a removal.
  • MetricChange.delta is null if the metric only exists on one side.
  • Edges by (fromId, toId), with weight and type-only aggregated.
  • Cycles by SCC, comparing the set of ids in the component. A size change counts as one cycle resolved and another introduced.
  • Hotspots by maxComplexity (or rloc if missing), only with a positive delta; a new file starts at 0. No opaque composite formula.
  • The migration section compares the migration lens of the base with that of the head: status by language and category, debt, FFI crossings, the files whose debt changes and those with the most debt. Its deltas are head - base as in the rest of the report: in debt, a negative delta is good.
  • The symbols section compares the symbols lens of the base with that of the head: per file, the symbols that were added, removed or changed, with their complexity before and after. A body change also counts, even when complexity and lines do not change. It matches by name within the same file; recognising renames and moves is the chain’s job (ADR 0026).
  • Every symbol carries addedLines and removedLines, the lines it adds and removes. That is not the file’s rloc delta: StartFn can add 10 lines and remove 4 inside a file that grows 11 net. They do not come from the snapshots, which store no code: they are measured by whoever has both sources at hand —the HEAD comparison, with the exported base and the tree— and injected as source. From two loose cc.json they stay null, which means “not measured”, not zero.
  • totals stores each metric’s total in the base and the head, with its delta: it sums additive metrics and takes the maximum of those that already are a maximum (maxComplexity, maxFunctionLength…). A metric one side does not declare comes out null, and so does its delta. The Changes tab paints the table from these totals; without the section, it does not paint it.
  • The duplication section compares the duplication lens of the base with that of the head: duplicated and repeated lines, the pairs that appear, those that stop sharing and those drifting apart, with their similarity before and after. Pairs are matched by their two paths, not by id: a pair is the same pair if the files are the same files. With the lens on only one side there is no comparison: lines.delta is null and the report says which side is unmeasured.
  • schemaVersion goes up when the report’s shape changes. Readers reconstruct reports from previous versions to the current one, so the newest is always used: what could not be measured (the migration section in a 1.0, duplication before 1.5, totals before 1.6) is marked null instead of inventing zeros.
  • Violations are compared by a stable key between base and head; only introduced ones with error severity block with --fail-on violations.
  • report.json is deterministic and has no timestamps. See ADR 0010 and ADR 0011.
walkSourceFiles(root)
→ root + nested .gitignore, the .codarch.yml `ignore`, each parser's extensions
+ Rust/WASM/Node, stable order
parserFor(parsers, path)
→ the parser for the extension (TS/JS, Java, Python, Go, C#); the cache is signed with its name
parser.parse(source, path)
→ loc/rloc/functions/complexity + imports + symbols and members
parser.importTargets(import, path) · ImportResolver(root, files)
→ what each language's import points at; C#'s `using` closes at the end of the analysis
measureRustSource(source)
→ lexical loc/rloc, no imports or complexity
collectGitChurn(root, files)
→ number_of_commits, number_of_authors (a single git log)
buildGraph(files, links)
→ dedupe, fan-in/out (all), runtime SCC, cycles and levels
buildMigration(files, ffiLinks)
→ statuses, debt, crossings and summary by language
buildSnapshot({ projectName, files, graph, migration })
→ tree, metrics/dependency/migration lenses, checksum
diffSnapshots(base, head, { rules })
→ files/edges/cycles/hotspots + introduced/existing/resolved violations
loadArchitectureStore(snapshot, report)
→ read-only index, shared risk, SCC, folder aggregates
MCP tools
→ map, hotspots, impact, violations and explanation; without re-analysing

Each phase is pure or almost pure and has its own tests. The analyser does not keep tree-sitter trees: it processes one file, extracts aggregates and moves on.

The scope is not guessed: besides .gitignore, codarch analyze, codarch serve and the browser analysis read the ignore list from the root .codarch.yml (ADR 0038). An excluded file never reaches the pipeline, so it does not appear in the snapshot, the metrics, the cycles or the checksum. --ignore adds patterns for one run and --no-config disables reading the file; diff, render and inspect do not filter again because they already consume the snapshot.

For the checksum to mean something:

  1. Canonical serialisation: sorted keys, no spaces, UTF-8.
  2. Nodes sorted by path; edges by (from, to); SCCs and cycles sorted.
  3. No timestamps, absolute paths, user names or locale.
  4. Numbers with a stable representation (no 1.0 vs 1).
  5. Filesystem traversal order is irrelevant by construction.
  6. Churn derived from the commit history, not from the run time; no dates or authors in the payload, and outside the checksum.

The current checksum is MD5 of canonically serialised { files, lenses }, with one exception: the metrics lens enters without number_of_commits or number_of_authors, nor their descriptors or types. Identity is the code, so the CLI with git, --no-git, the browser and any clone give the same number (ADR 0103). History stays in the snapshot: when it is measured, meta.historyChecksum hashes its values to tell “same code, another heat” from “another code”. A snapshot without history has no such field: it is unmeasured, not at zero.

MD5 is enough to detect payload changes; if it is later used as a cache identifier between machines, it can be migrated to SHA-256 without changing the contract.

Conformance test: analysing the same commit twice produces the same snapshot and the same checksum, and with or without git it is the same. It lives in packages/parsers/test/analyze.test.ts and packages/parsers/test/git.test.ts.

  1. Symbols or only files? v1 aggregates complexity per file (maxComplexity, functions) without modelling classes and functions as nodes. Modelling them gives more precise hotspots, but multiplies the graph size and the parser cost.
  2. Symbol-level edges? Same as above: cc.json 2.x’s leafEdges exists, but it is another order of magnitude.
  3. Where does the cache live? A .codarch/cache with content hashes allows incremental analysis. JSON files or SQLite?
  4. Is the report the snapshot? I propose not: snapshot = data; report = derived findings, with its own version.
  5. Churn inside the snapshot or derived? Decided in M2: inside the snapshot as number_of_commits and number_of_authors, so the diff is self-contained. A shallow clone produces a warning; there is no relative time window. Since ADR 0103 it does not enter the checksum: heat has its own meta.historyChecksum.