Metadata-Version: 2.5
Name: graphify-cli
Version: 0.1.0
Summary: Ask your codebase structural questions instead of grepping for them: a dependency graph across every repo in a workspace.
Project-URL: Homepage, https://github.com/ChihebBENCHEIKH1/graphify
Project-URL: Issues, https://github.com/ChihebBENCHEIKH1/graphify/issues
Author: chiheb ben cheikh
License-Expression: MIT
License-File: LICENSE
Keywords: codebase-navigation,dependency-graph,kotlin,ktor,microservices,monorepo,static-analysis,tree-sitter
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Documentation
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.10
Requires-Dist: tomli; python_version < '3.11'
Requires-Dist: tree-sitter-kotlin==1.1.0
Requires-Dist: tree-sitter-typescript
Requires-Dist: tree-sitter==0.25.2
Provides-Extra: dev
Requires-Dist: pytest; extra == 'dev'
Description-Content-Type: text/markdown

# 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.

```bash
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

```bash
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.

```bash
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.
