Skip to main content

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

  • @Serializable wire 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 @SerialName discriminators — 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 invoke interfaces, a dependency call reads getItemUseCase(id) rather than getItemUseCase.execute(id); each class's constructor is used as a symbol table so those bare calls resolve.
  • Ktor @Resource route trees, with URLs reconstructed by walking the parent chain.

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@mapNotNull skips one item while a bare return stops 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: explain walks 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(...) and throw with 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 (a CallToolResult with isError) rather than by being named error. 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.md files 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 ServiceLoader SPI is; a dynamically assembled routing key is not.
  • Test sources are excluded by default. Pass --include-tests to 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 stats reports the counts.
  • HTTP coverage: the Ktor @Resource surface is complete, and annotation-based legacy routers are indexed too. Route DSL declared inline — outside a /routes/ directory and outside a *Routes.kt file — 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 a binary_expression. parsers/koin.py matches 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

graphify_cli-0.1.0.tar.gz (107.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

graphify_cli-0.1.0-py3-none-any.whl (115.2 kB view details)

Uploaded Python 3

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

Hashes for graphify_cli-0.1.0.tar.gz
Algorithm Hash digest
SHA256 ed94fd03eff118415bfedea4913ebda1d291b3a82cc8d7063b386c6161391e94
MD5 8656534c6d567b3b80a80c73c02840b2
BLAKE2b-256 d477aa45ecd987d836d8da4dee6190ed0332c13561defa4b5a90a81186cee7b5

See more details on using hashes here.

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

Hashes for graphify_cli-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ffbcb5efc287c734dbf030816dc63de59a09e6be374bb2426112f3376933ea76
MD5 60579f0b581bca6a48e1a1b477f4dfe2
BLAKE2b-256 c822a2bd5f9816f497b85f5f2b52e093ea9e5f661c434af3d32adc19a9844849

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page