{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreicjipjulv7eliwxtl2nn24liikwauxwxsnlnyvpchvr4h6ausfe4q",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3motr2szxygu2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreif5ea756uuh3446tx45du7el2lgbne7flyv7whwzyatox6ups7feq"
    },
    "mimeType": "image/webp",
    "size": 83060
  },
  "path": "/neko1313_4/graphlens-a-polyglot-code-analysis-framework-that-turns-your-repo-into-a-typed-graph-4mhi",
  "publishedAt": "2026-06-22T01:19:45.000Z",
  "site": "https://dev.to",
  "tags": [
    "ai",
    "python",
    "staticanalysis",
    "opensource",
    "graphlens",
    "ty",
    "gopls",
    "rust-analyzer",
    "https://github.com/Neko1313/graphlens",
    "https://Neko1313.github.io/graphlens/"
  ],
  "textContent": "#  graphlens: turn any repo into one typed graph — across Python, TypeScript, Go and Rust\n\nEvery code-intelligence tool I've ever used falls into one of two traps.\n\nThe first is the **grep-and-read loop** : you (or your AI agent) search for a name, open ten files, read around the matches, follow an import, search again. It works, but it's slow, it burns tokens, and it has no idea that the `process_order` you found in `services.py` is the _same_ `process_order` that gets called from `api.py` — versus the unrelated one in `tests/`.\n\nThe second is the **single-language silo** : tools that understand Python beautifully but go blind the moment your TypeScript front end calls a Python FastAPI route. Real systems are polyglot. Your tooling usually isn't.\n\ngraphlens is an open-source (MIT) framework built to escape both traps. It parses a source project, normalizes its structure into a shared **graph IR** , and hands you that graph to do whatever you want with — dependency analysis, navigation, dead-code detection, or feeding an LLM agent precise answers instead of file dumps.\n\n\n\n    Repository → Language Adapter → GraphLens (IR) → Graph Backend\n\n\nLayer | Responsibility\n---|---\n**Language Adapter** | Parses source files, produces a `GraphLens`\n**GraphLens** | Typed nodes + directed relations — the intermediate representation\n**Graph Backend** | Persists or queries the graph (Neo4j, in-memory, your own)\n\nThe key design decision: **adapters are pure data producers.** They never write to a database, never touch the filesystem after reading, never run a server. The graph is the only output. That makes the whole pipeline trivially testable, cacheable, and serializable.\n\n##  30 seconds to your first graph\n\n\n    pip install \"graphlens-cli[python]\"\n    graphlens analyze ./my-project\n\n\n\n    graphlens · my-project\n      nodes:      1240\n      relations:  3981\n      resolver:   ok\n\n    nodes by kind        relations by kind\n      FUNCTION    410       CONTAINS    980\n      METHOD      265       DECLARES    870\n      CLASS        98       CALLS       640\n      MODULE       54       REFERENCES  410\n\n\nOr from Python:\n\n\n\n    from pathlib import Path\n    from graphlens import adapter_registry\n\n    adapter = adapter_registry.load(\"python\")()\n    graph = adapter.analyze(Path(\"./my-project\"))\n\n    print(len(graph.nodes), \"nodes,\", len(graph.relations), \"relations\")\n\n    fn = graph.nodes_by_name(\"process_order\")[0]\n    print(\"called by:\", [n.name for n in graph.callers(fn.id)])\n\n\n##  What makes the edges _real_ (and not name-matching guesses)\n\nMost lightweight code-graph tools resolve references by name: see a call to `save()`, draw an edge to anything called `save`. That's fast and wrong — there are usually a dozen `save`s in a codebase.\n\ngraphlens splits the work in two:\n\n  1. **Tree-sitter** parses every file into a concrete syntax tree, giving exact structure and 1-based span positions. It records every _use-site_ as an **occurrence** with a role (call / read / write / annotation / base).\n  2. A language-specific, **type-aware resolver** then answers `definition_at(file, line, col)` for each occurrence. The resolved definition becomes a real edge to the _actual_ declaration node.\n\n\n\nLanguage | Resolver | Engine\n---|---|---\nPython | `TyResolver` |  ty (Astral, Rust-based) via LSP\nTypeScript | `TsResolver` | the TypeScript Compiler API (Node subprocess)\nGo | `GoplsResolver` | gopls\nRust | `RustAnalyzerResolver` | rust-analyzer\n\nSo a `CALLS` edge points at the real function, a `HAS_TYPE` edge at the real class, an `INHERITS_FROM` edge at the real base. This is the difference between \"probably related\" and \"is related\".\n\n###  Honesty about partial failures\n\nType analysis can degrade — a toolchain is missing, a file doesn't type-check. Instead of silently producing a half-resolved graph, graphlens records the outcome:\n\n\n\n    from graphlens import RESOLVER_STATUS_KEY\n    graph.metadata[RESOLVER_STATUS_KEY]   # 'ok' | 'degraded' | 'unavailable'\n\n\nIn CI you flip on `--strict` and a non-`ok` status fails the build, so an agent or dashboard never consumes a graph that's quietly incomplete.\n\n##  The graph model\n\n**Nodes** (`PROJECT`, `MODULE`, `FILE`, `CLASS`, `METHOD`, `FUNCTION`, `PARAMETER`, `VARIABLE`, `ATTRIBUTE`, `TYPE_ALIAS`, `IMPORT`, `DEPENDENCY`, `EXTERNAL_SYMBOL`, `BOUNDARY`) are frozen dataclasses with an id, kind, qualified name, file path, span, and free-form metadata.\n\n**Relations** are directed, typed edges:\n\nKind | Meaning\n---|---\n`CONTAINS` / `DECLARES` | structural containment & declaration\n`IMPORTS` / `RESOLVES_TO` | import statements and where they resolve\n`CALLS` / `REFERENCES` / `INHERITS_FROM` / `HAS_TYPE` | resolved, type-aware edges\n`DEPENDS_ON` | declared package dependency\n`EXPOSES` / `CONSUMES` / `COMMUNICATES_WITH` | cross-language boundaries\n\n###  Deterministic IDs\n\nA node's ID is a SHA-256 hash of `project::kind::qualified_name`:\n\n\n\n    from graphlens import make_node_id\n    make_node_id(\"my-project\", \"my.module.func\", \"FUNCTION\")\n    # → the same id every scan, on every machine\n\n\nBecause the ID depends only on identity, not file position, re-scanning yields the same IDs. That's what makes `graph.diff(other)` and incremental updates work — and what makes a graph cacheable in CI.\n\n##  The feature single-language tools can't have: cross-language boundaries\n\nThis is my favorite part. Adapters emit language-agnostic **`BOUNDARY`** nodes for the interfaces a service exposes or consumes — HTTP routes, queue topics, gRPC methods, Temporal activities — with an `EXPOSES` edge (provider) or `CONSUMES` edge (consumer).\n\nA boundary's ID is `make_boundary_id(mechanism, key)` — _no project or language in it_. HTTP paths are normalized so that `/users/1`, `/users/{user_id}` (FastAPI), `<int:id>` (Flask), and `:id` (Express) all collapse to `GET /users/{}`.\n\nThe payoff: a Python FastAPI route and a TypeScript `fetch` to the same endpoint produce the **same** boundary ID. Merge the two graphs, run `graphlens-link`, and you get `COMMUNICATES_WITH` edges spanning the language gap:\n\n\n\n    from graphlens import adapter_registry\n    from graphlens_link import link_graph\n\n    py = adapter_registry.load(\"python\")().analyze(python_project)\n    ts = adapter_registry.load(\"typescript\")().analyze(typescript_project)\n\n    merged = py\n    merged.merge(ts, allow_shared=True)   # identical BOUNDARY nodes coincide\n    result = link_graph(merged)           # adds consumer → provider edges\n\n    print(result.relations_added, \"COMMUNICATES_WITH edges added\")\n\n\nNow you can answer \"which front-end calls hit this endpoint?\" — a question no single-language tool can even represent.\n\n##  Five ways to use it\n\n**As a library** — load an adapter, get a `GraphLens`, query it: callers, callees, references, neighborhoods, diffs, JSON round-trips, multi-language merges.\n\n**From the CLI** — five subcommands cover the common workflows:\n\n\n\n    graphlens analyze ./repo --output graph.json   # index\n    graphlens query process_order -g graph.json --op callers\n    graphlens visualize ./repo                      # interactive vis.js HTML\n    graphlens neo4j ./repo --uri bolt://localhost:7687\n    graphlens mcp --graph graph.json                # serve to agents\n\n\n**In CI** — `--strict` plus a Docker image (`ghcr.io/neko1313/graphlens`) with every adapter and toolchain pre-installed. Index on every push, publish the graph as an artifact, fail on a degraded graph.\n\n**To LLM agents over MCP** — `graphlens mcp` exposes a saved graph as Model Context Protocol query tools (`stats`, `find`, `callers`, `callees`, `references`, `neighbors`, `boundaries`, `communicates_with`). Instead of dumping a codebase into the prompt, the agent asks precise questions and gets small structured answers — resolved edges, not best-effort text search.\n\n**As a Neo4j export** — straight into a graph database with `UNWIND … MERGE` Cypher (no APOC required), then query it however you like.\n\n##  Plugin architecture: the SQLAlchemy-dialect pattern\n\nThe core never imports an adapter. Each language is a separate package that registers itself via Python entry points:\n\n\n\n    [project.entry-points.\"graphlens.adapters\"]\n    python = \"graphlens_python:PythonAdapter\"\n\n\nCallers resolve adapters through a registry, by name string:\n\n\n\n    adapter_registry.available()        # ['python', 'typescript', ...]\n    adapter = adapter_registry.load(\"python\")()\n\n\nAdding a new language means writing one package against the `LanguageAdapter` contract — no changes to the core.\n\n##  What graphlens is _not_\n\nThe scope is deliberately narrow, and the docs spell it out. graphlens produces a graph IR and stops there. It does **not** :\n\n  * persist state or own a database (backends are a separate consuming layer);\n  * watch the filesystem or re-index incrementally on its own (scans are pure functions; deterministic IDs _enable_ incremental updates, but the caller drives them);\n  * compute embeddings, semantic search, or relevance ranking (the graph is structural and type-aware, not a vector index);\n  * provide a UI or an agent runtime (`visualize` emits static HTML, `mcp` exposes query tools — neither hosts a long-running service).\n\n\n\nThose belong to tools built _on top of_ graphlens. Keeping the core minimal is what keeps it composable.\n\n##  Benchmarks\n\nThroughput on real-world projects, refreshed on every release inside the published Docker image (single cold run, indicative):\n\nProject | Lang | LOC | Nodes | Time | Resolved\n---|---|---|---|---|---\napache/superset | python | 399 519 | 156 251 | 148.7s | 84%\ncolinhacks/zod | typescript | 74 194 | 8 741 | 19.0s | 91%\ngin-gonic/gin | go | 23 672 | 7 227 | 13.9s | 100%\ngohugoio/hugo | go | 224 821 | 34 809 | 112.7s | 99%\nBurntSushi/ripgrep | rust | 50 275 | 9 612 | 113.1s | 99%\n\n##  Try it\n\n\n    pip install \"graphlens-cli[python]\"\n    graphlens analyze . --output graph.json\n    graphlens visualize .\n\n\n  * **Repo:** https://github.com/Neko1313/graphlens\n  * **Docs:** https://Neko1313.github.io/graphlens/\n  * **Requirements:** Python 3.13+. Python (`ty`) and TypeScript (Node) toolchains install on demand; Go and Rust adapters come via the Docker image.\n\n\n\nIf you've ever wanted a single, accurate, language-agnostic model of \"how does this codebase actually fit together\" — that's exactly what graphlens hands you. I'd love feedback, issues, and adapter contributions.",
  "title": "graphlens: a polyglot code-analysis framework that turns your repo into a typed graph"
}