stratigraph
v1.3.0
Published
Read the layers of a codebase: static facts from source and git history, assembled into a dependency graph, hotspots, and grounded structural findings.
Maintainers
Readme
stratigraph
Read the layers of a codebase.
Stratigraphy is how archaeologists read a site: layer by layer, deducing the
order things happened from what sits on top of what. stratigraph does the same
to a large codebase — it reads the source, reads the git history, and
reconstructs how the thing came to be shaped the way it is.
Aimed at monoliths and multi-module builds of 100k+ LOC where nobody remembers why things are the way they are. Java/Spring Boot and Angular first.
Status: M4. The Java extractor, the package graph, history mining, the interpretation layer and the MCP server all work. Pointed at apache/dubbo — 4,053 Java files, no Spring Boot,
javax.*, Spring XML wiring — it produces 47,350 nodes and 163,693 edges in 18 seconds and reports 17 package cycles across 652 packages, three verified by hand against the cited lines. It then mines 8,893 commits in 4.5 seconds (332 MB peak), resolving 6,189 paths through rename chains, and finds 3,303 co-changing file pairs the dependency graph cannot explain.It then clusters those packages and has a model name them — under a citation check that rejects any sentence naming something the model was not shown. Across dubbo's 38 evidence packs that check sits at 38 of 38 grounded descriptions accepted and zero fabrications missed, and it is re-run against five kinds of mutated identifier on every change to the rule.
All of it is then served over MCP, and a fresh Claude Code with dubbo out of context answered five structural questions from it correctly — every answer checked by hand against the lines it cited.
M5 adds the second stack. The Angular extractor reads TypeScript with the compiler API and templates with
@angular/compiler— and deliberately not with@angular/compiler-cli, so it works on a repository that has never been installed. Pointed at bitwarden/clients with nonode_modulespresent, it produces 42,016 nodes and 76,179 edges from 5,788 sources in 5.6 seconds, including 7,024 DI edges, 165 routes and 4,013 component relationships read out of templates. 87% of the DI edges resolve through the type checker, which is what follows a barrel re-export to the file the class is actually declared in.Both extractors write into one run, so an Angular service and the Spring endpoint it calls can be joined — as an inference, never as a fact.
The report layer (M6) is still ahead.
The rule that shapes everything
Static analysis produces facts. The LLM produces interpretation. The LLM never invents a fact.
Every node and edge comes from a parser, from git log, or from a build file.
Every claim in a generated report carries a provenance reference — a file and
line, a commit sha, or a fact-table row. When the model wants to assert
something it cannot cite, the correct output is "no evidence found".
This is enforced in the schema, not only in prompts: interpretation lives in
different tables from facts, edges are marked fact or inferred, and a
citation row has a CHECK constraint forcing it to point at something real.
See ADR-0002.
The whole pipeline runs with --no-llm and still produces a useful report.
Install
npx stratigraph --helpRequires Node 18.18 or newer. Nothing else, until you analyse Java — the Java extractor needs a JDK 17+ available, and tells you so rather than crashing:
stratigraph doctorok stratigraph v1.0.1, fact-store schema v1
ok node v20.11.1 on darwin-arm64
ok git git version 2.50.1
warn java 1.8.0_432 from JAVA_HOME is below JDK 17; the Java extractor
will not run (this limits the analyser, not the code it can analyse)
warn extractor Java extractor jar not found — build it with
`cd extractors/java && ./mvnw package`
ok config defaults (no stratigraph.config.json found)
warn model claude-opus-5, but no credential found
-- database .stratigraph/my-repo.db does not exist yet — run `stratigraph init`A Docker image is the second channel, for environments where you would rather not think about toolchains at all. See ADR-0004.
Set your API key
Only if you want cluster names and ADR candidates. Everything structural —
the dependency graph, cycles, clusters, hotspots, coupling, the intent-vs-
structure findings — runs with no key and no configuration at all. Skip this
section entirely and use analyze --no-llm.
Pick one. They are tried in this order:
stratigraph config set-key sk-ant-... # writes ~/.config/stratigraph/config.json, chmod 600export ANTHROPIC_API_KEY=sk-ant-... # this shell; put it in ~/.zshrc to keep itecho 'ANTHROPIC_API_KEY=sk-ant-...' >> .env # this directory; gitignore itant auth login # no key at all, if you use the Anthropic CLIA Claude Pro or Max subscription is not API credit. Those cover Claude.ai and Claude Code; this tool calls
api.anthropic.com, which is billed separately from the Console. A key from an account with no API balance authenticates fine and then fails with "Your credit balance is too low".ant auth logindoes not change this — the OAuth token carries auser:inferencescope but the organisation still needs credit. Interpretation is the only part affected; everything structural runs regardless.
Then confirm — it prints where the key came from, never the key:
$ stratigraph doctor
ok model claude-opus-5, credential from ~/.config/stratigraph/config.jsonNot sure what is picked up? stratigraph config lists every file that can
affect a run, whether or not it exists, and which one won:
$ stratigraph config
Files that configure a run, weakest first. Later ones win.
found /home/me/.config/stratigraph/config.json you, every repository
absent /home/me/work/stratigraph.config.json this project, committed (no key here)
absent /home/me/work/stratigraph.config.local.json this project, your machine
absent /home/me/work/.env environment, e.g. ANTHROPIC_API_KEY
model claude-opus-5
credential /home/me/.config/stratigraph/config.jsonA key never goes in stratigraph.config.json. That file is meant to be
committed, and the tool refuses to load one that contains a key — by the time
anyone notices, it has to be rotated rather than deleted. The full reference,
including per-project settings and apiKeyFile, is under
Configuration.
Use
stratigraph init --repo ../some-monolith # create the fact store
stratigraph extract --repo ../some-monolith # run every applicable extractor into it
stratigraph history --repo ../some-monolith # mine git: churn, complexity, authors
stratigraph analyze --repo ../some-monolith # cycles, clusters, coupling, hotspots, ownership
stratigraph mcp --repo ../some-monolith # serve it all to an agent over MCPanalyze --no-llm is the whole report minus the prose: clusters, mismatches,
cycles, coupling, hotspots and ownership all come out of algorithms. The model
adds names and readings on top of that, and never replaces any of it.
history attaches to the run extract opened, so both halves share one
run_id — which is what lets analyze say exactly which co-changing files
have no dependency between them. Either half works alone: on a machine with no
JDK, init + history + analyze still produces a full history report, and
analyze says which sections it could not fill rather than leaving them out.
analyze prints each cycle as a path with the edges that justify every hop:
1. [high] com.example.web → com.example.service → com.example.repo → com.example.web
com.example.web → com.example.service
imports web.OrderController → service.OrderService [src/web/OrderController.java:9]
...Every hop names a file and a line, because a dependency cycle you cannot check is not worth reporting. Cycles are stored as findings with citations into the edges that produced them, never as facts — see ADR-0008.
extract runs every extractor the repository needs, detected from what is
on disk, and writes them all into one run — which is what lets an Angular
service and the Spring endpoint it calls be joined at all, since nodes are
scoped by run_id. --lang java|ts|all overrides the detection. A missing JDK
skips the Java half and keeps going rather than failing the command.
stratigraph extract --emit writes the raw NDJSON to stdout instead of storing
it, and stratigraph ingest --from facts.ndjson replays a captured stream.
Angular, and the endpoint it might be calling
The TypeScript extractor emits components, injectables, NgModules, DI edges,
routes with their lazy-loaded boundaries, and the component-to-component edges
that only exist inside a template. Directories become package nodes, so cycle
detection, clustering and every MCP query work on Angular with no change — a
package cycle in app/admin/metrics reads exactly like one in
com.example.web, with the template line cited as evidence where a template is
what created it.
Then analyze matches Angular HTTP calls against Spring endpoints:
1 cross-stack HTTP call(s) — INFERRED, not observed:
GET /api/orders/{} -> GET /api/orders/{id}
web/src/app/core/order.service:OrderService#findOne()
web/src/app/core/order.service.ts:15
Matched by URL pattern against a declared endpoint. Nothing in either file
says these are connected; check the cited lines before relying on one.Every one of those edges is stored with confidence = 'inferred' and is
excluded from the package graph, so no cycle can be assembled out of a string
match. Only literal and template-literal URLs are matched; a computed one gets
a diagnostic and no edge, and a URL matching two endpoints equally well gets a
diagnostic naming both rather than a coin toss
(ADR-0018, which also
records what this cost on a real JHipster monolith: nothing at all, honestly).
It also reports subscriptions nothing can end — no takeUntil, no retained
Subscription, no ngOnDestroy on the class. All three have to hold, so a
teardown it cannot see produces silence rather than an accusation.
History, and the coupling nobody wrote down
The output worth having is the second section: files that change together over and over with nothing in the code connecting them. The static graph cannot see that by construction.
Files that change together with no dependency between them (top 20):
1. src/main/java/com/example/order/OrderService.java
src/main/resources/db/migration/V12__order_status.sql
31 shared commits — strength 0.86, 9.4x chance (36 and 41 commits respectively)Every such pair is stored as a finding citing the commit shas that produced it,
so git show settles any disagreement. Nothing is filtered silently: the
report says how many commits were considered, how many were too broad to pair,
and how many pairs each threshold removed — an empty section reads like a clean
repository unless it says what was examined.
Three things decide whether that output is signal or noise, and all three are written down in ADR-0011: merge commits are excluded, commits touching more than 50 files take no part in pairing (one repo-wide reformat otherwise couples everything it touched), and a pair must co-change more often than chance, not merely often.
Renames are followed, so a file moved three years ago has one history rather
than two halves. git log --follow cannot do this — it takes exactly one
pathspec — so the miner makes one whole-repository pass and resolves the rename
chains itself (ADR-0009).
Alongside it: hotspots ranked by churn × complexity, and files whose history is concentrated in one person. Complexity is total indentation — a proxy, named as one, chosen because it needs no parser and therefore also scores the XML, SQL and properties files that turn up in coupling pairs constantly.
stratigraph history --repo ../some-monolith --since '3 years ago'
stratigraph analyze --repo ../some-monolith --top 40Clusters, and the packages whose name is a lie
analyze groups packages into communities over one graph built from both
layers: the dependency edges the extractor observed, and the co-change the
history miner measured. History is allowed to move a package into the group it
actually belongs to, which is the point of combining them at all.
3 package clusters (modularity 0.412, coupling weight 1):
1. Order handling (com.example.shop.order) — 7 packages
Serves the order lifecycle and persists it.
com.example.shop.order.api
...coupling weight is printed because it decides the answer: at 0 the grouping
is purely structural, and raising it lets history outvote the imports. Run it
again with --coupling-weight 0 and see what moves. The algorithm is Louvain
with every source of randomness removed, so the same facts always give the same
clusters — a partition that shifted between runs would make every finding built
on it unfalsifiable (ADR-0012).
The finding worth the milestone is the next one:
1. [high] shop.billing.report is named under shop.billing but clusters with shop.admin
All of the 3 packages named under shop.billing sit in shop.billing:
shop.billing.invoice, shop.billing.ledger, shop.billing.payment.
This one sits in shop.admin instead.
connected to shop.admin.role: imports shop.billing.report.A → shop.admin.role.A
(src/shop/billing/report/A.java:201)A package name is a path, so the packages sharing its parent path are its
declared neighbourhood, and "named alongside these three, clustered with those
three" is arithmetic — not a model's opinion about what report sounds like.
The model may later describe what the two responsibilities appear to be; it
never decides that a mismatch exists
(ADR-0014).
Interpretation, and what stops it inventing things
Names, descriptions and ADR candidates are the only things a model writes, and they are written under a contract enforced in code rather than in the prompt.
Each cluster is packed into a numbered list of evidence — packages, edges with
file and line, files with their churn, commits — where every item carries an
opaque, pack-local id (e12, not the database's edge id). A model that
guesses a database id lands on a real row, so guessing fails open. A model that
guesses e99 in a pack of twelve lands on nothing.
Four rules reject a response outright:
- a citation that is not an id in the pack;
- a claim that cites nothing;
- any identifier, path or commit sha in the prose that the pack did not contain — this is the one that catches a real citation attached to a sentence about a class that does not exist;
- an ADR candidate whose evidence does not resolve.
A rejected response is retried once with the violations attached, then
discarded. Nothing rejected is ever stored, and every give-up leaves a
diagnostic row — because a cluster nobody could describe must not read like
one nobody tried to describe
(ADR-0013).
Interpretation by claude-opus-5 — 12 of 14 clusters described.
Names and descriptions above this line are inference, not observation.
2 description(s) failed the citation check and were discarded — see the diagnostic table.What the check cannot do is verify that the evidence supports the sentence. A
model can cite seven genuine imports and draw the wrong conclusion from all of
them. That is exactly why the mismatch claim above stays algorithmic, and why
everything model-authored is stored with authored_by = 'model', the model id
that answered, and a report that says which lines are inference.
Without a credential — or with --no-llm — analyze says so in one line and
prints the structural report unchanged.
Ask it questions from an agent
stratigraph mcp serves the fact store over MCP on stdio, so an agent working
in the codebase can ask structural questions instead of grepping for them.
claude mcp add stratigraph -- stratigraph mcp --repo ../some-monolithNine tools, all read-only:
| Tool | Answers |
| --- | --- |
| describe_run | What this store contains, and — the point of it — what it does not |
| find_node | Resolve a name you have to the exact fqn the other tools take |
| query_dependencies | What a package or type depends on, and what depends on it |
| find_callers | Every observed call or injection into a method or type |
| describe_module | One package in full: types, endpoints, tables, churn, cluster |
| list_endpoints | The HTTP surface, with the method that serves each route |
| find_hotspots | Churn × complexity, or files whose history is one person |
| trace_to_table | The types mapped to a table, and what reaches them |
| check_cycle | Whether two packages depend on each other, with the edges |
The server only reads. It opens the database read-only and never starts an extractor: a stale store is reported as stale, not silently rebuilt, because a tool call is a bad place to start a JVM over 4,000 files. It also pins one run at startup, so two answers in a session cannot describe two different commits.
Every result carries a file and line, an fqn or a sha. Anything a model wrote
comes back labelled authoredBy: "model" and is never blended into the
structural answer. And every tool that can return nothing says which kind of
nothing it is — found for "that name is not in this run", covered for "this
run could not have answered that" — because to an agent an empty array reads as
"there is no such thing" either way
(ADR-0015).
Pointed at dubbo, with the repository itself out of context, it answered five
structural questions correctly — a cycle with both directions cited, the sole
call site of a method, a bus-factor file, a package summary — and each answer
was checked by hand against the lines it cited. One of its numbers disagreed
with git log --follow, and the tool turned out to be right; the run is
recorded in the ADR.
The Java extractor
Needs a JDK 17+ to run in; it parses source of any vintage, including Java 8. It parses the source set and never runs or resolves your build (ADR-0006), so it works on a repository that does not compile, has no build file, or uses a layout nobody has used since Ant. Plain core Java with no framework at all gets the full structural output — package graph, cycles and all.
Until the first release with a jar attached, build it from a checkout:
cd extractors/java && ./mvnw packagestratigraph doctor reports where it found the jar and when it was built.
What it cannot see without a classpath is stated rather than guessed: meta-annotated custom stereotypes, members inherited from third-party supertypes, anything an annotation processor generates, and bean wiring defined in XML. Each of those produces a diagnostic and an absence, never a wrong edge.
The TypeScript extractor
Needs no JDK and no download — it ships inside this package and runs on the Node already executing, as a separate process so that a parser exhausting memory on a huge workspace takes down one extractor rather than the analysis.
It parses the source set and never installs or type-checks your project
(ADR-0016), which is
why it does not use @angular/compiler-cli: NgtscProgram needs installed
node_modules, a resolvable tsconfig.json and a project that compiles, and
the repositories this tool exists for routinely have none of the three.
tsconfig.json is read as plain JSON for compilerOptions.paths and nothing
else — those aliases matter, because in an Nx workspace they are the only way
cross-project imports are ever written.
What it cannot see is stated rather than guessed: anything declared in a package
you have not installed (an is_stub node, named by the import that introduced
it), template type-checking, and DI through a factory or an InjectionToken.
Each produces a diagnostic and an absence, never a wrong edge.
The fact store defaults to .stratigraph/<repo-name>.db under your current
directory, never inside the repository being analysed.
Configuration
No config file is required. stratigraph analyze --no-llm needs none at all,
and every value below has a working default. You create a file to change
something — see Set your API key for the common case.
stratigraph config # what is in play right now
stratigraph init --write-config # scaffold stratigraph.config.jsoninit --write-config writes the defaults spelled out, so the file is a menu of
what can be changed rather than decisions made on your behalf. It never
overwrites an existing one.
Where the files go
Four places, none of which exists until you create it, merged weakest first:
| File | Scope | Committed? | Key allowed? |
| --- | --- | --- | --- |
| ~/.config/stratigraph/config.json | you, every repo you analyse | no — outside every repo | yes |
| stratigraph.config.json | one project, everyone on it | yes | no |
| stratigraph.config.local.json | one project, your machine | no — .gitignore it | yes |
| .env | environment variables | no — .gitignore it | yes |
The project files and .env are looked up in the working directory first,
then in the repository being analysed — so cd ~/work && stratigraph analyze
--repo ../monolith reads ~/work/stratigraph.config.json, then falls back to
../monolith/stratigraph.config.json. --config <path> overrides the lookup.
On Windows the user file is %APPDATA%\stratigraph\config.json.
XDG_CONFIG_HOME and STRATIGRAPH_CONFIG_HOME are both honoured.
Precedence, highest first: CLI flags → local → project → user → defaults. A
project pinning "model": "claude-opus-5" beats your personal default; your
.local.json beats the project. A variable already exported always beats
.env, so a committed .env cannot override a secret CI set.
Unknown keys are an error, not a shrug. An explicit null means "use the
default", which is why the scaffolded file can spell every option out.
The credential, in full
| Where | How |
| --- | --- |
| llm.apiKey | Inline. Allowed in the user file and *.local.json; refused in stratigraph.config.json |
| llm.apiKeyFile | Path to a file holding the key. ~ expands; relative paths resolve against the config file |
| llm.apiKeyEnv | Which environment variable to read — default ANTHROPIC_API_KEY, so a team can point at WORK_ANTHROPIC_KEY |
| .env | Sets that variable, if it is not already exported |
| — | ANTHROPIC_AUTH_TOKEN, or a profile from ant auth login |
A configured apiKeyFile that cannot be read is an error, rather than a quiet
fall-through to whatever else is lying around: silently using a different
credential than the one you asked for is how the wrong account gets billed.
Without a credential, analyze prints one line saying so — with the three
commands that would fix it — and the structural report is unchanged.
What you can set
The full shape is in
stratigraph.config.example.json, which
ships with the package. The three worth knowing:
llm.model— defaults toclaude-opus-5.--model <id>overrides it per run. Whichever model answered is recorded on every row it writes.interpret.couplingWeight— decides the clustering, soanalyzeprints the value it used.0clusters on structure alone.interpret.maxClusters— caps how many clusters are sent to the model, so a large repository cannot run away with your bill.
llm.sendSource is off by default and loudly logged when on. Extraction and
history mining are entirely local; only the interpretation layer talks to a
model API, and only about structural metadata unless you opt in.
Architecture
Five layers, strictly one-directional. Layers do not reach backwards; presenters never call extractors.
extractors ──NDJSON──▶ fact store ──▶ history miner ──▶ interpreters ──▶ presenters
(Java: JVM) (SQLite) (git log) (clustering + LLM) (MCP server,
(TS: compiler API) Mermaid,
C4, HTML)Extractors are separate processes that emit newline-delimited JSON on stdout.
The core never links against a parser, which is why a JVM-only Java parser and a
Node-only Angular parser can coexist without either infecting the core
(ADR-0001,
ADR-0003). The TypeScript extractor is
Node like the core, so the boundary is enforced by the build rather than by
convention: it compiles under its own rootDir, and an import from src/ into
it fails to compile.
Development
npm install
npm test # vitest
npm run typecheck
npm run build
npm run stratigraph -- doctor # run the CLI from source
cd extractors/java && ./mvnw verify # the extractor and its golden testsThe TypeScript suite never needs a JDK — the core is tested against a fake extractor that prints canned NDJSON, because the protocol is the whole contract. The extractor's own suite asserts exact fact output for every fixture, and CI runs one job where a real jar meets the real fact store.
It needs git for exactly one test, which builds its own repository with fixed
dates and authors. Everything else about history is driven from a captured
git log in fixtures/git-log/ or from seeded rows, so the three-OS matrix
depends on no binary it did not install.
Conventions:
- Test-first for anything in the fact layer. A parser change without a fixture test does not get committed.
- Fixtures in
fixtures/are tiny, hand-written, and assert exact fact output. The one exception isfixtures/git-log/, captured from a real git — it asserts what git emits, and inventing that from memory is how a parser ends up handling a format nobody produces. - No speculative abstraction: two concrete implementations before an interface.
- Every non-obvious decision gets an ADR in
docs/adr/.
Licence
Apache-2.0.
