graphify
Originally built for the ControlResell workspace, and open-sourced because that shape is not unusual.
A dependency graph across every repo in a workspace, so that questions spanning several of them can be asked instead of answered by reading source trees into a context window.
It targets the stack a Kotlin service mesh is usually made of — Ktor @Resource route
trees, Koin bindings, Exposed tables, kotlinx.serialization wire DTOs and sealed payload
unions, RabbitMQ topology — plus the TypeScript and Python that sit around it.
Once a system is split across repos, no single repo contains the answer to "who consumes this DTO", "what breaks if I change this routing key", or "which service handles this job variant". Those facts exist only in the relationships between repos, which is what this builds.
graphify explain SendInvoiceWorkflowImpl # ← start here: the full briefing
graphify build # ~2s warm, writes graph.json + ../ARCHITECTURE_GRAPH.md
graphify key orders.events.completed # publishers, subscribers, payload types
graphify contract CreateOrderPayload # fields + every consumer, across repos
graphify callers SendEmailUseCase --depth 2 # inbound call / injection hierarchy
graphify impact JobPayload # blast radius of a sealed union, grouped by repo
graphify deps my-service-worker # pins in, pins out
graphify routes my-service-api # reconstructed HTTP route table
graphify workflow Checkout # derived state machine of a workflow
graphify table Orders # columns + DTO mapping + schema drift
graphify find Account # locate an ambiguous name across repos
graphify trace "Skipping retry for order" # a reported message -> the emitting line
graphify status OrderStatus # where a domain status is written
graphify docs --drift-only # doc claims that no longer hold
graphify know di-conventions # what has already been verified, with evidence
graphify doctor # self-check invariants + report drift
graphify stats # graph composition
Add --json before any subcommand for machine-readable output:
graphify --json key orders.events.completed.
Outputs
| File | Size | Purpose |
|---|---|---|
../ARCHITECTURE_GRAPH.md |
~20 KB | The cross-service surface. Read this once; it fits in context. |
graph.json |
~10 MB | The full graph. Never read directly — query it. |
meta.json |
~2 KB | Provenance: generation time, per-phase stats, repo HEADs. |
What it extracts
Contracts and events
@Serializablewire DTOs, with their fields, nullability and defaults. Types additionally marked for TypeScript code-generation (@Zodable) are distinguished from those that stay server-side, since only the first kind can break a frontend.- Sealed wire unions and their
@SerialNamediscriminators — the shape most cross-service payloads take. - The RabbitMQ topology: every routing key, who publishes it, who binds it, and the payload
type on each end — reconstructed from message-broker send methods, factory key lists, and
when (routingKey)dispatch tables. - Cross-package contract edges, and the K8s CronJobs that publish over the RabbitMQ management API.
Structure and call hierarchy
extends/implements, including generic supertypes — which is how a handler usually declares the payload it handles (TaskHandler<JobPayload.CreateOrder>).- Koin DI bindings (interface → implementation) and constructor injection.
- Call edges. Because use cases are
operator fun invokeinterfaces, a dependency call readsgetItemUseCase(id)rather thangetItemUseCase.execute(id); each class's constructor is used as a symbol table so those bare calls resolve. - Ktor
@Resourceroute trees, with URLs reconstructed by walking theparentchain.
Behaviour (not just structure)
- KDoc for every declaration and DTO field. In a well-documented codebase most of "what does this do" is already written down and simply needed indexing.
- Guard clauses: the early returns that stop a use case. Classified by exit scope,
because
return@mapNotNullskips one item while a barereturnstops the work — reporting the first as "it stopped here" sends an investigation the wrong way. - Workflow state machines, derived from the
when(step)dispatch in each*WorkflowImpl: synchronous advances, the routing keys a step suspends on, and the history rows it writes. - Entry points:
explainwalks backwards to whatever actually triggers a class — the routing key, cron or HTTP route — rather than just naming its immediate caller. - Tests mapped to their subject, with backticked case names as behaviour prose.
Failure index
- Every
logger.error/warn/info,error(...)andthrowwith a message, indexed by its literal segments so a rendered message (values already substituted) matches back to the template. Interpolation holes are removed,+concatenation is joined, and a an MCP result helper is detected by what it BUILDS (aCallToolResultwithisError) rather than by being namederror. Kotlin only — TypeScript and Python messages are not indexed — and ~8% of messages carry no literal run long enough to match.
Domain status writes
- Where each domain status is set, and by what. Deliberately not a transition graph: only about half of writes have a statically knowable from-state, so it reports the writes it can prove and counts the opaque ones separately. Enums that merely classify rather than transition are excluded — nothing moves through them.
Doc verification
- The claims the workspace's
CLAUDE.mdfiles make — file paths, routing keys, symbols, gradle modules, version pins — checked against the code. Precision-first: an ambiguous claim (a bare filename, a metasyntactic path, an approximate count) is reported as unchecked rather than guessed at, because a false drift report costs more trust than a missed one.
Data layer
- All Exposed tables and columns, with nullability, defaults, indexes and references. Since there is no migration tool, DTO fields with no matching column are flagged.
Across languages
- TypeScript imports from code-generated contract packages, linked back to the Kotlin declaration they were generated from.
- Python/FastAPI routes and message-bus handlers.
How it works
tree-sitter for real AST parsing (Kotlin, TypeScript/TSX), Python's ast for FastAPI,
and TOML/regex for build files. Parsing the whole workspace takes about 6 seconds.
Symbol resolution is import-directed: a bare Foo resolves against the file's own import
list first, then its package, then a globally unique simple name. That ordering is what makes
a multi-repo graph trustworthy — names like Account, Order or Buyer routinely mean
different things in different services, and a naive global match silently wires two unrelated
subsystems together.
Everything workspace-specific lives in extract.py, messaging.py and httproutes.py. The
parsers in parsers/ only report what the source says, so they stay correct when the house
style shifts.
Out of scope
graphify is a deterministic AST and regex tool. It does not parse English, and it will
not grow NLP heuristics to look thorough. Three classes of documentation claim are
therefore OUT_OF_SCOPE, permanently:
| Shape | Example | Why not |
|---|---|---|
| Enumerations stated as prose | "i18n FR+EN mandatory" when the app ships eight locales | Requires knowing a two-item list was meant to be exhaustive |
| Counts whose counting rule is unstated | "~25 route groups" | Several defensible answers: route-builder calls, route directories, registrations |
| Scoped or threshold numbers | "478 issues across 4 modules", "from ≥3 repos" | Reads as a total and is not one — both produced false drift before the whitelist tightened |
Cardinality checks are therefore restricted to a closed whitelist of nouns whose count cannot plausibly be scoped — routing keys, database tables, locale directories — plus version pins and file paths, which are structured by construction.
The consequence is worth stating plainly: real drift can exist that the tool cannot see —
a prose count, an unstated enumeration, a number that turned out to be scoped. A clean
graphify docs means the structured claims hold, not that the docs are true.
Self-checking
graphify doctor asserts the invariants that would make the graph untrustworthy, and
separately reports drift it merely observed. It exits non-zero on a failure, so it works
in CI:
- parse health (>99% of Kotlin files must parse cleanly)
- publish-site coverage — every
send*call site in source must have become an edge. This is the regression guard that would have caught the top-level-route-function blind spot that once dropped 15 publishes. - no code edge may cross a repo boundary the build graph does not permit
- cross-repo fully-qualified-name collisions must stay distinct nodes
The warnings are facts about the codebase, not bugs: artifacts pinned at several different versions across repos, and routing keys with no publisher or no subscriber.
Known limits
- Static only. Reflection and runtime-built names are followed only where the pattern is
recognised. A
ServiceLoaderSPI is; a dynamically assembled routing key is not. - Test sources are excluded by default. Pass
--include-teststo include them. - Edges are never guessed. If a type cannot be resolved, no edge is emitted — so recall is
imperfect by design and precision is favoured.
graphify statsreports the counts. - HTTP coverage: the Ktor
@Resourcesurface is complete, and annotation-based legacy routers are indexed too. Route DSL declared inline — outside a/routes/directory and outside a*Routes.ktfile — is not found. - A stale checkout makes a stale graph. Repo versions and commit SHAs are recorded in the summary's last table so drift is visible rather than silent.
- The tree-sitter Kotlin grammar cannot parse
single<Iface> { Impl() }— without type information it is indistinguishable from a chain of<comparisons, so it lands as abinary_expression.parsers/koin.pymatches that mis-parse shape deliberately; see the comment there before "fixing" it.
Adding a repo
Nothing to configure. workspace.py discovers repos from build files at build time, so a repo
cloned into the workspace is picked up on the next graphify build.
Verified knowledge
graphify know <topic> returns what has already been established about your workspace —
conventions, gotchas and counts — each with its file:line evidence.
The point is to stop paying twice. Facts like "a handler is registered through a
META-INF/services SPI file naming its provider" or "Exposed keeps the CamelCase object
name verbatim for an unnamed table" are each established once by a real investigation.
Without somewhere to put them they get re-derived every session.
The store lives in kb/claims.json, which is not shipped with this repo — a knowledge
base describes one workspace's internals, which makes it both useless to anyone else and
unwise to publish. graphify know is simply empty on a fresh clone; everything else works.
Build your own with --reseed, or point KB in cli.py somewhere else.
It is also the one artifact that cannot be rebuilt from source: the graph takes about ten seconds, but these claims come from investigations that read far more than the parsers do. They are re-verified against the tree on every build, so a claim that stops holding is reported stale rather than trusted forever.
Claims are self-verifying: each carries a declarative check — a count, a file
predicate, a node or edge lookup — re-evaluated on every build. Checks are data, never
executable code. A claim whose check fails is marked STALE with the reason, and never
silently trusted. Not every claim is mechanically checkable; the ones that are not are
reported as UNCHECKED rather than counted as verified.
Freshness
Every query stats the source tree (~110ms) and rebuilds if anything changed, naming the
files that moved. A stale graph answers confidently and wrongly, and nothing about the
answer looks off — that is the failure mode most likely to waste an hour, so it is made
impossible rather than documented. --stale-ok skips the check.
Builds are incremental: parsed files are cached and re-used, so a no-op rebuild is ~2s against ~6s cold. The cache is keyed on the parser source as well as on file content, so changing a parser invalidates it rather than replaying objects that predate a new field.
Install
pip install graphify # then run `graphify` from anywhere inside your workspace
Python 3.10+ is the only requirement. From a source checkout, ./graphify <anything>
creates its own virtualenv and installs the package into it on first run — set
GRAPHIFY_PYTHON=python3.12 to pick a different interpreter.
skill/SKILL.md is a Claude Code skill definition — drop it in ~/.claude/skills/graphify/
and set the path at the top so Claude reaches for the graph before grepping.
Where it thinks the workspace is
The root is discovered, not configured: the current directory and its parents are tried
first, then the directory graphify itself lives in, stopping at the first one holding two or
more repos. So an installed graphify works from anywhere inside a workspace, and a
checkout works whether it sits beside the repos or inside one of them.
graphify root # the resolved workspace root
GRAPHIFY_ROOT=/path/to/ws graphify build # override for any other layout
A wrong root produces an empty graph rather than an error, so build prints the root it
used and shouts when it finds fewer than two repos.
Where it writes
| Default | Override | |
|---|---|---|
| Graph and caches | $XDG_CACHE_HOME/graphify/<workspace>-<hash>/, or out/ in a checkout |
GRAPHIFY_CACHE |
| Knowledge store | kb/claims.json in a checkout, else next to the cache |
GRAPHIFY_KB |
| Summary | <workspace root>/ARCHITECTURE_GRAPH.md |
--summary |
Derived data is keyed by workspace path on purpose. One installed copy of graphify serves every project on the machine, so a single shared output directory would let two workspaces overwrite each other's graph — and each would then answer questions about the other's code without anything looking wrong.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file graphify_cli-0.1.0.tar.gz.
File metadata
- Download URL: graphify_cli-0.1.0.tar.gz
- Upload date:
- Size: 107.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ed94fd03eff118415bfedea4913ebda1d291b3a82cc8d7063b386c6161391e94
|
|
| MD5 |
8656534c6d567b3b80a80c73c02840b2
|
|
| BLAKE2b-256 |
d477aa45ecd987d836d8da4dee6190ed0332c13561defa4b5a90a81186cee7b5
|
File details
Details for the file graphify_cli-0.1.0-py3-none-any.whl.
File metadata
- Download URL: graphify_cli-0.1.0-py3-none-any.whl
- Upload date:
- Size: 115.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ffbcb5efc287c734dbf030816dc63de59a09e6be374bb2426112f3376933ea76
|
|
| MD5 |
60579f0b581bca6a48e1a1b477f4dfe2
|
|
| BLAKE2b-256 |
c822a2bd5f9816f497b85f5f2b52e093ea9e5f661c434af3d32adc19a9844849
|