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.
Three layers, one truth
Section titled “Three layers, one truth”identity tree of files and folders, with stable idssignals additive lenses: metrics, dependencies, domain, migration…graph typed edges between ids, not inside the treeThe 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.
Snapshot
Section titled “Snapshot”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.
Identity
Section titled “Identity”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.tsis the only implementation of this rule.
Compatibility with cc.json 1.x
Section titled “Compatibility with cc.json 1.x”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.attributeTypesandattributeDescriptorsare split between metrics and dependencies.blacklist,markedPackagesandfixedPositionare 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.
Lenses
Section titled “Lenses”Each lens is a map id → data. Unknown means it is preserved as-is when re-exporting.
| Lens | Contents | v1 |
|---|---|---|
metrics | numeric attributes per node + descriptors | yes |
dependency | edges, levels, cycles, layers | yes |
risk | composite score, violations | derived at render time, not stored |
migration | LOC per language, status, debt and FFI boundary | yes (v1) |
symbols | what is inside each file: functions, classes, types and module variables | yes (M11) |
structure | class members and the file’s imports/exports | yes (M44) |
duplication | shared lines across files and within one, and copy/diverging pairs | yes (M68) |
domain | words, frequency, tf-idf | later |
evolution | time series, temporal coupling | later |
Metrics
Section titled “Metrics”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:
| Group | Metrics |
|---|---|
| Parser | loc, rloc, functions, maxComplexity, maxFunctionLength |
| Imports | imports, externalImports, builtinImports, typeOnlyImports, unresolvedImports |
| Graph | fanIn, fanOut (all imports), inCycle, cycleSize, dependencyLevel (runtime) |
| History | number_of_commits, number_of_authors |
| Migration | migrationDebt, ffiCrossings |
| Duplication | duplicatedLines, 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).
The migration lens
Section titled “The migration lens”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 duplication lens
Section titled “The duplication lens”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 symbols lens
Section titled “The symbols lens”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,letorvarwith 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 ✓.
The structure that is not a symbol
Section titled “The structure that is not a symbol”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,privateandreadonly) 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 andtype; exports with declarations,default,*,* as nsand 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 and violations
Section titled “Rules and violations”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: 1layers: - 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: errorThe 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.deltaisnullif 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(orrlocif missing), only with a positive delta; a new file starts at 0. No opaque composite formula. - The
migrationsection 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 arehead - baseas in the rest of the report: in debt, a negative delta is good. - The
symbolssection 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
addedLinesandremovedLines, the lines it adds and removes. That is not the file’srlocdelta:StartFncan 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 assource. From two loosecc.jsonthey staynull, which means “not measured”, not zero. totalsstores 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 outnull, and so does its delta. The Changes tab paints the table from these totals; without the section, it does not paint it.- The
duplicationsection 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.deltaisnulland the report says which side is unmeasured. schemaVersiongoes 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 a1.0, duplication before1.5, totals before1.6) is markednullinstead of inventing zeros.- Violations are compared by a stable key between base and head; only introduced ones with
errorseverity block with--fail-on violations. report.jsonis deterministic and has no timestamps. See ADR 0010 and ADR 0011.
M1/M2/M3 pipeline
Section titled “M1/M2/M3 pipeline”walkSourceFiles(root) → root + nested .gitignore, the .codarch.yml `ignore`, each parser's extensions + Rust/WASM/Node, stable orderparserFor(parsers, path) → the parser for the extension (TS/JS, Java, Python, Go, C#); the cache is signed with its nameparser.parse(source, path) → loc/rloc/functions/complexity + imports + symbols and membersparser.importTargets(import, path) · ImportResolver(root, files) → what each language's import points at; C#'s `using` closes at the end of the analysismeasureRustSource(source) → lexical loc/rloc, no imports or complexitycollectGitChurn(root, files) → number_of_commits, number_of_authors (a single git log)buildGraph(files, links) → dedupe, fan-in/out (all), runtime SCC, cycles and levelsbuildMigration(files, ffiLinks) → statuses, debt, crossings and summary by languagebuildSnapshot({ projectName, files, graph, migration }) → tree, metrics/dependency/migration lenses, checksumdiffSnapshots(base, head, { rules }) → files/edges/cycles/hotspots + introduced/existing/resolved violationsloadArchitectureStore(snapshot, report) → read-only index, shared risk, SCC, folder aggregatesMCP tools → map, hotspots, impact, violations and explanation; without re-analysingEach 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.
Determinism
Section titled “Determinism”For the checksum to mean something:
- Canonical serialisation: sorted keys, no spaces, UTF-8.
- Nodes sorted by path; edges by
(from, to); SCCs and cycles sorted. - No timestamps, absolute paths, user names or locale.
- Numbers with a stable representation (no
1.0vs1). - Filesystem traversal order is irrelevant by construction.
- 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.
Open questions
Section titled “Open questions”- 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. - Symbol-level edges? Same as above: cc.json 2.x’s
leafEdgesexists, but it is another order of magnitude. - Where does the cache live? A
.codarch/cachewith content hashes allows incremental analysis. JSON files or SQLite? - Is the report the snapshot? I propose not: snapshot = data; report = derived findings, with its own version.
- Churn inside the snapshot or derived? Decided in M2: inside the snapshot as
number_of_commitsandnumber_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 ownmeta.historyChecksum.