On this page
- The five kinds of cross-language link
- 1. A shared runtime
- 2. Bindings and build steps
- 3. Network contracts
- 4. Shared data
- 5. Published packages
- Co-change, which is a different signal
- Matching HTTP calls across repos
- How repowise links repos, and where it stops
- How to test any tool's cross-repo claim
- How we measured
- FAQ
A cross-language dependency graph resolves imports inside each language and stops there, because no import crosses from TypeScript into Go. The links between repos and languages run over HTTP routes, gRPC, queues, shared tables and published packages, so a tool has to match providers to consumers by name. Sourcegraph, GitNexus, Code System Graph and repowise each cover part of that.
| Tool | What it links across repos | How | License or plan | Limit |
|---|---|---|---|---|
| Sourcegraph precise code navigation | Symbols: go to definition and find references across repos and package dependencies | Compile-time SCIP indexes per repo (Go, TS/JS, C/C++, Java/Kotlin/Scala, Rust, Python, Ruby, C#) | Enterprise plan | Symbol references, not network calls. Each repo needs an index |
| GitNexus | HTTP routes, RPC definitions, message topics within a repo group | gitnexus group sync builds a contract registry | PolyForm Noncommercial | Commercial use needs a separate license |
| Code System Graph | HTTP, OpenAPI, gRPC, events (Kafka, RabbitMQ, SNS/SQS, NATS, Pub/Sub), schemas, packages, owners | Static extraction into one federated graph, served over MCP | Apache-2.0 | Early project (v1.2.1), five languages |
| MindGraph | Includes, calls and type references across repos | Static extraction, served over MCP | MIT | C and C++ first, other languages secondary |
| Backstage catalog | Whatever teams declare: dependsOn, providesApis, consumesApis | YAML files written by people | Apache-2.0 | Only as current as the last edit |
| Tempo service graph | Calls that actually happened between services, queues and databases | OpenTelemetry trace spans turned into metrics | AGPL-3.0 | Only traced traffic; no file or line |
| repowise workspace | Packages, HTTP routes to clients, gRPC, topics, sockets, tables, and cross-repo co-change | Static extraction plus git history; typed edges marked exact or candidate | CLI AGPL-3.0; hosted workspaces on Pro and Teams | Needs 2+ repos in a workspace; names built at runtime are skipped |
Scroll the table sideways to see every column.
The five kinds of cross-language link
The phrase covers five different kinds of link, and they fail in different ways. A tool that says "cross-language" without saying which kind is usually doing the first one.
1. A shared runtime
Java, Kotlin and Scala compile to the same JVM, so a Kotlin file can import a Java class by name. TypeScript and JavaScript share modules the same way, and Vue and Svelte components are TypeScript underneath. Here a real symbol edge across languages is possible. repowise, for example, puts Java, Kotlin and Scala in one shared JVM index, so a Kotlin call can resolve to the Java method it names (Scala's import resolution is partial).
2. Bindings and build steps
Examples are Python calling a C extension and JavaScript calling Rust through WebAssembly or napi (Node-API, Node's interface for native addons). A build step makes the link, so static graphs mostly show two separate islands. On react/react's architecture page, the Rust crates under compiler/crates show up as their own community next to the JavaScript packages. That is the honest picture, because no import statement connects the two.
3. Network contracts
Service A calls POST /v1/orders; service B serves it. Or A publishes to orders.created and B consumes it. Nothing in either file names the other, so the tool has to extract every route and every client call, then match them by path or topic. This is where most of the value is in a microservice system, and where most of the errors are.
4. Shared data
Two services that migrate or query the same table are coupled through the database. Matching an ORM model to a migration is fairly reliable. Matching raw SQL strings in app code is a heuristic.
5. Published packages
Repo B depends on @acme/auth, which repo A publishes. This is the easiest kind to get right, from manifests alone, and the one monorepo build tools already handle once the code is in one place. Migrating 50+ projects into a monorepo covers that case.
Co-change, which is a different signal
Files in different repos that keep changing in the same week give a sixth signal, which is coupling with no dependency behind it. That coupling is real and often the most useful kind, and a graph that mixes it with imports will tell you two files call each other when they don't. Hidden coupling and co-change detection explains how it is computed.
Matching HTTP calls across repos
Extracting a route is the easy half, because @app.get("/users/{id}") is plain text. The hard half is the client side.
- Base URLs hide the service.
fetch(`${API_BASE}/users/${id}`)doesn't say which serviceAPI_BASEpoints at, and the value is often in an environment file or a deploy config the code never reads. - Router prefixes change the path. A handler at
/usersmounted underapp.use('/api', router)in another file is really/api/users, and if the tool misses the prefix, nothing matches. - Client wrappers hide the call. Most codebases call an
apiClient.get()wrapper instead offetch, so the tool has to follow the wrapper back to its base URL. - Names built at runtime can't be resolved. Nobody can statically resolve a topic name assembled from a tenant ID.
The right response to each is to label the link: exact when exactly one service provides that path, candidate when several could, and skipped when the name is built at runtime. A tool that draws every plausible edge with the same confidence will look more complete and be wrong more often.
How repowise links repos, and where it stops
I build repowise, so this is the precise version. In a single repo it builds a file and symbol graph from parsed imports and calls, with each edge stamped by how it was resolved, plus co-change from git history. It resolves npm, yarn and pnpm workspaces and tsconfig path aliases in TypeScript, Go multi-module go.mod, Maven and Gradle reactors, Cargo and .csproj projects, so imports across packages inside one repo resolve to files.
Cross-repo linking is a separate layer, called a workspace. Put two or more repos under one folder and run repowise init .. It then extracts:
- HTTP routes and the client calls that hit them, across Express, NestJS, Next.js, FastAPI, Flask, Django, Spring, ASP.NET, Laravel, gin, Axum and more, with router prefixes and axios or ky base URLs stitched in.
- gRPC services from
.protofiles and client stubs; Kafka, RabbitMQ, NATS, Redis, BullMQ, SQS and SNS topics; socket events; database tables from migrations and ORM models. - Package dependencies from
package.json,composer.json,pyproject.toml,Cargo.toml,go.mod,.csprojand Mavenpom.xml. - Co-change pairs across repos, kept as a separate edge kind.
Everything lands in one system graph where nodes are services and every edge carries a kind, a match type (exact, candidate, manual or inferred), and pointers back to the evidence. repowise workspace diagnostics lists each unmatched call with a reason: no provider, internal only, unlinked, or an external host like Stripe.
These are the limits:
- The cross-repo layer needs at least two repos in a workspace. A single monorepo indexed alone gets the import graph but no HTTP contract layer.
- repowise skips names built at runtime and does not guess them. If
API_BASEis unresolved and several services serve that path, you get a candidate, or you map it yourself underservice_basesin the workspace config. - SQL strings in app code are matched heuristically, and data contracts get no field-level breaking-change check.
- Calls through bindings or WebAssembly aren't linked.
- Inside one repo, roughly fifteen percent of call edges are wrong in our own hand-graded sample, concentrated in Java, Rust and C++. That figure is from the graph layer docs, with the method.
The cross-repo docs list every framework and client the extractors read. For how the single-repo graph is built, see building a dependency graph for any codebase.
How to test any tool's cross-repo claim
These checks work on every tool in the table, including ours.
- Pick a call you know, such as one frontend request and the backend route it hits, and see whether the tool links them and points at the right handler.
- Ask for the misses. A tool that can list the calls it located but couldn't match is telling you its recall (how many of the real links it finds), and one that can't is asking you to trust it.
- Look for confidence labels, because exact and guessed links should look different.
- Try a dynamic name, such as a URL from an environment variable or a topic built from a string. The right answer is "unresolved", and a confident edge is a wrong answer.
- Check co-change separately. If a tool says two files depend on each other, ask whether that came from code or from history.
For JavaScript and TypeScript inside one repo, 5 ways to generate a JS/TS dependency graph has runnable commands for madge, dependency-cruiser, Nx, esbuild and repowise.
How we measured
On 2026-10-06 we read each tool's own docs or README for the claims in the table: Sourcegraph's code navigation docs, the GitNexus and Code System Graph READMEs, MindGraph's listing, Backstage's descriptor format and Grafana Tempo's service graph docs. We did not run the other tools on a shared set of repos, so the table says what each one documents and cannot say how well it does it. repowise claims come from the OSS docs and source at commit e83befa, and the react/react observation from its live architecture page the same day.
FAQ
What is a cross-repo call graph?
It is a graph of which functions call which, where the caller and callee live in different repositories. Inside one language and runtime, tools like Sourcegraph's precise code navigation can do this at the symbol level when every repo has an index. Across services the "call" is usually an HTTP or gRPC request, so tools link the client call to the route that serves it instead.
How do I map dependencies across projects in different repos?
Start with manifests: list which internal packages each repo depends on. Then extract network contracts (routes, gRPC services, topics) and match providers to consumers. Finally add co-change from git history to catch coupling no code shows. A catalog like Backstage records what teams declare, and traces show what actually ran.
What is multi-repo code analysis?
It means running the same analysis over several repositories and joining the results: one graph, one search, one set of findings. The join is the hard part, because each repo is indexed on its own and the links between them have to be inferred from contracts, packages and history.
Can static analysis find microservice dependencies?
Static analysis finds part of them: routes, client calls, topics and tables that are written as literals or resolvable constants. It can't see a base URL that only exists in a deploy config, or a name built at runtime. Pair it with traces if you need the full runtime picture.
Is co-change a dependency?
Co-change is not a dependency. Two files that keep changing in the same commits or the same week are coupled, and that is worth knowing, but neither has to import the other. Treat co-change as its own edge kind and keep it apart from imports.