Understanding code: who wrote it, what it does, what its architecture is
Three ways to read code that already exists, written by people, by archec gen, or by an AI
assistant.
Who wrote the code: the model, an AI assistant, or a person#
With AI writing code, a reviewer needs to know who wrote what. archec provenance main.arche (or archec audit main.arche --authors) gives each line of code one origin:
- generated:
archec genwrote it and it is unchanged (gen leaves.archegen.jsonbeside its output, a fingerprint per line); - AI: an AI assistant wrote it: its commit has a
Co-Authored-By: Claude…,Generated-by:orAI-assisted:trailer, or an assistant's address, or a marker in.arche/provenance.jsonlsays so; - edited after gen: gen wrote the file and a person changed the line;
- human: the rest.
It reads git blame, so each line counts for the commit that last changed it. It sums
the lines by file and by part of the model (the files a part names with source or
code, and those of its parts), and lists the files an assistant wrote that no person
committed to since. archec diagram main.arche Car --overlay provenance colours each
block by who wrote most of its code; archec doc --html --provenance adds the tables to
the specification.
For Claude Code, add the hook to .claude/settings.json: it marks the lines Claude
writes as it writes them.
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|MultiEdit|Write",
"hooks": [ { "type": "command", "command": "archec provenance hook" } ] } ] } }
Other assistants can call archec provenance mark <file> --lines 10-40 --tool <name>.
See the commands.
Behaviour from traces: what the code actually does#
Code written fast, or by an AI, does things nobody wrote down. archec observe reads what it did — its
OpenTelemetry traces (OTLP JSON, as the SDK's or the collector's file exporter writes them), or a Jaeger
or Zipkin export — and writes it as a model to read and draw.
cd test/fixtures/observe
archec observe shop.otlp.jsonl --states -o observed.arche
archec diagram observed.arche post-checkout-error # a sequence diagram
archec diagram observed.arche Observed.orders.states # a state diagram
Each service is a part. Each call from one service to another is a message on a link, with how many were
seen, how long they took (p50, p95) and how many failed. A database or a cache called with no trace of its
own is a part too. The traces are grouped by the way they went: one scenario per shape, with how often it
occurred. Calls that overlap are drawn in a par, the same call in a row in a loop.
// observed: 9 of 64 trace(s) went this way (14%)
scenario post-checkout-error "POST /checkout (payment declined)" {
sequence post-checkout-error-steps "POST /checkout (payment declined)" {
Observed.outside -> Observed.gateway : POST_CHECKOUT
Observed.gateway -> Observed.orders : POST_ORDERS
Observed.orders -> Observed.orders : validate_cart
par {
when "RESERVE" { … }
when "CHARGE" {
Observed.orders -> Observed.payments : CHARGE
Observed.payments -> Observed.orders : CHARGE "error: card declined" { reply }
}
}
Observed.orders -> Observed.inventory : RELEASE
…
--states adds a state machine to each service that waits on another. Its states are the replies it
awaits; a comment says in how many traces each transition was taken:
// observed: in 9 of 64 trace(s)
AwaitingCharge -> AwaitingRelease on CHARGE when "the reply is an error" / "sends RELEASE"
With a model, --model main.arche compares instead: services no part is, calls the model has no link or
message for, links never seen, a link's deadline against the time on the wire, and the checks of
archec replay. archec replay reads the same traces. See the commands.
From code to model#
The rest of arche-lang goes from the model to code. archec recover goes the other way, for code
nobody drew — written over years, or by an AI in an afternoon:
archec recover src -o recovered.arche # TypeScript, JavaScript, Go, Python, C/C++, Rust
archec diagram recovered.arche --md # see it
It reads each file's imports with a small scanner per language (no compiler, nothing installed), and what
the file does with them: how often it uses each name it imports, and how — a call, a new X (a struct
literal in Go and Rust), a type, another value. An import used often weighs more (about 1 + log₂ of its
uses); one that takes only types weighs half, a re-export (export … from) ½, one never used ¼–½.
TypeScript and JavaScript are read with the TypeScript compiler's parser when it is there (archec's own,
or the working folder's node_modules), else lexically like the other languages: the file's names,
strings and comments left out, matched with what each import binds (import { a as b } binds b,
import x.y binds x.y, Go's package name is used as pkg.Name, a C header binds what it declares).
In Go the files of one package use each other with no import at all: what a file uses of the names
another file of its package declares is a reference between the two, weighed the same way.
It makes a part of each folder, cuts a folder of many files into groups — first families of names that
import one another (websim*, parser*), then the rest by coupling (the weights above; a file many
import counts less towards where each of them goes), each group named after its stem or the file others
import most — and writes one link per pair of parts with the number of imports (and references).
A file many import that imports at most two files of its folder is a library: kept apart as common;
one many import that imports much (a program's centre) is placed by coupling like the others. A file
beside folders that one of them uses, and that has most of its links with it, joins that folder's part
(// recovered: says which). Among sibling parts, one that two thirds of the others use and that uses
them a quarter as much or less is written kind library: what it imports back is said on it, and left
out of the cycles, so that a cycle is the knot of the design and not a helper imported back. Cycles are
broken by their lightest links by weight: an import of types only goes first. The standard
library (Node's own modules, Python's, Go's, libc, Rust's std) is left out: every part would link to it
(--std keeps it). Each part says its files with source, and the names others import from it are its
functions. What it had to guess is a // recovered: comment:
/// 14 files, 3 996 lines.
component Importer "importer" {
kind module
source "src/importer/**"
in api: call
out uses: call
fn import_project "importProject"
fn import_sysml "importSysml"
…
/// 4 files, 1 182 lines.
component sysml "importer/sysml*" {
kind module
source "src/importer/sysml.ts"
…
}
sysml.uses -> ir.api "8 imports"
}
system ArcheLang "arche-lang" {
prop recovered = "archec recover src"
…
// recovered: a cycle: 25 parts import one another; 53 light links break it (archec recover --json lists them). To shrink it first, cut:
// recovered: what common imports from generics, libraries, parser (+2) (11 imports, src/program.ts:18…): 22 parts left in a cycle
// recovered: 107 weak links (fewer than 3 imports) not drawn: weak_links lists them, archec conform allows them (--min-imports 1 draws all)
prop weak_links = [
"board -> gen: 1 import",
…
]
cli.uses -> importer.api "8 imports"
…
}
A block of more than 12 links keeps as links those of 3 imports or more; the weaker ones are listed in
prop weak_links, so nothing is lost (--min-imports N sets the bar in every block, --min-imports 1
draws every link). A cycle is said by the light links that break it, each with an import's file and line
(a feedback arc set weighted by imports), or, for a big one, by what to cut first. archec diagram draws
at most the 30 heaviest links of a recovered model's block (prop recovered says it is one; each block its
heaviest link first) and says how many it left out; --max-links N sets it for any model whose links say
their weight ("12 imports"), --max-links 0 draws all.
archec recover --json gives each link its imports, references, weight and uses. How close the
grouping comes to a model drawn by hand: node tools/recover-score.mjs compares archec recover src
with arche-lang's own model (examples/arche-lang), pairs of files placed together by both, as precision
and recall.
Rename the parts, regroup them, remove the links you do not want: that is now the architecture you mean.
archec conform keeps it true — the reflexion model: each import of the code between two parts must be a
link of the model (else an error at the import's file and line), each link should have imports under it
(else a warning), and each file should belong to a part. A pair of parts listed in weak_links is allowed:
its imports are counted apart, neither convergence nor divergence (remove the line to forbid it).
Divergences: imports the model has no link for (1, between 1 pair(s) of parts)
error Code.core → Code.util: 1 import(s)
src/core/engine.ts:1 imports '../util/log' (Code.util)
Code: 5 of 5 files mapped; 4 link(s) converge, 1 import(s) diverge, 0 link(s) absent
✗ the code does what the model does not say
archec ci runs it as a gate when a source names a file of the code. archec arch-diff src main..HEAD --md recovers two revisions from git and writes, for the pull request, the parts and links the branch
added or removed, the files it moved between parts, and with --model the divergences it brought in. The
second revision is grouped as the first: a folder that crossed 12 files is cut into groups, or kept whole,
as before, and said under Folders rather than as files moved.
Its limits: imports are read, not types or calls (--graph gitnexus adds calls from a GitNexus index);
an import of a computed name is only said; the grouping is a first proposal, not a design.