graphlens: превращаем репозиторий в типизированный граф — Python, TypeScript, Go и Rust в одной модели

от автора

Любой инструмент для «понимания кода», которым я пользовался, рано или поздно упирался в одну из двух стен.

Первая — цикл «grep → открыть → прочитать → перейти по импорту → снова grep». Работает, но медленно, и у него нет ни малейшего представления о том, что process_order, найденный в services.py — это тот самый process_order, который вызывается из api.py, а не однофамилец из tests/. Когда этим занимается LLM-агент, он ещё и сжигает на этом тонну токенов.

Вторая стена — моноязычность. Инструмент прекрасно понимает Python, но слепнет в ту секунду, когда фронтенд на TypeScript дёргает ручку FastAPI на Python. Реальные системы полиглотны. Инструменты вокруг них — обычно нет.

graphlens — это open-source фреймворк (MIT), который спроектирован так, чтобы обойти обе стены. Он парсит исходный проект, нормализует его структуру в общий граф-IR и отдаёт этот граф вам — делайте с ним что хотите: анализ зависимостей, навигацию, поиск мёртвого кода или подачу точных ответов LLM-агенту вместо вываливания файлов в контекст.

Repository → Language Adapter → GraphLens (IR) → Graph Backend

Слой

Ответственность

Language Adapter

Парсит исходники, производит GraphLens

GraphLens

Типизированные узлы + направленные связи — промежуточное представление

Graph Backend

Хранит или запрашивает граф (Neo4j, in-memory, ваш собственный)

Ключевое архитектурное решение: адаптеры — чистые продюсеры данных. Они никогда не пишут в базу, не трогают файловую систему после чтения, не поднимают сервер. Граф — единственный выход. Благодаря этому весь пайплайн тривиально тестируется, кэшируется и сериализуется.

Первый граф за 30 секунд

pip install "graphlens-cli[python]"graphlens analyze ./my-project
graphlens · my-project  nodes:      1240  relations:  3981  resolver:   oknodes by kind        relations by kind  FUNCTION    410       CONTAINS    980  METHOD      265       DECLARES    870  CLASS        98       CALLS       640  MODULE       54       REFERENCES  410

То же самое из Python:

from pathlib import Pathfrom graphlens import adapter_registryadapter = adapter_registry.load("python")()graph = adapter.analyze(Path("./my-project"))print(len(graph.nodes), "узлов,", len(graph.relations), "связей")fn = graph.nodes_by_name("process_order")[0]print("вызывается из:", [n.name for n in graph.callers(fn.id)])

Почему рёбра графа — настоящие, а не догадки по имени

Большинство лёгких инструментов «код → граф» резолвят ссылки по имени: видим вызов save() — рисуем ребро ко всему, что называется save. Быстро и неверно — в кодовой базе таких save обычно с десяток.

graphlens разделяет работу на два этапа:

  1. Tree-sitter парсит каждый файл в конкретное синтаксическое дерево (CST), даёт точную структуру и 1-based позиции спанов. Каждый use-site он фиксирует как occurrence с ролью (вызов / чтение / запись / аннотация / базовый класс).

  2. Затем type-aware резолвер, специфичный для языка, отвечает на definition_at(file, line, col) для каждого occurrence. Разрешённое определение становится настоящим ребром к реальному узлу-декларации.

Язык

Резолвер

Движок

Python

TyResolver

ty (Astral, на Rust) через LSP

TypeScript

TsResolver

TypeScript Compiler API (Node-субпроцесс)

Go

GoplsResolver

gopls

Rust

RustAnalyzerResolver

rust-analyzer

В итоге ребро CALLS указывает на реальную функцию, HAS_TYPE — на реальный класс, INHERITS_FROM — на реальный базовый класс. Это разница между «вероятно связано» и «связано».

Честность по поводу частичных сбоев

Типовой анализ может деградировать — тулчейн отсутствует, файл не проходит проверку типов. Вместо тихой выдачи наполовину разрешённого графа graphlens записывает результат:

from graphlens import RESOLVER_STATUS_KEYgraph.metadata[RESOLVER_STATUS_KEY]   # 'ok' | 'degraded' | 'unavailable'

Статус

Значение

ok

type-aware слой отработал до конца

degraded

резолвер запустился, но часть запросов упала

unavailable

резолвер вообще не стартовал (например, нет тулчейна)

В CI включается --strict — и любой статус, кроме ok, роняет сборку. Так агент или дашборд никогда не получит граф, который незаметно неполон.

Модель графа

Узлы (PROJECT, MODULE, FILE, CLASS, METHOD, FUNCTION, PARAMETER, VARIABLE, ATTRIBUTE, TYPE_ALIAS, IMPORT, DEPENDENCY, EXTERNAL_SYMBOL, BOUNDARY) — это frozen-dataclass’ы с id, видом (kind), квалифицированным именем, путём к файлу, спаном и произвольными метаданными.

Связи — направленные типизированные рёбра:

Вид

Смысл

CONTAINS / DECLARES

структурная вложенность и декларация

IMPORTS / RESOLVES_TO

операторы импорта и куда они разрешаются

CALLS / REFERENCES / INHERITS_FROM / HAS_TYPE

разрешённые type-aware рёбра

DEPENDS_ON

объявленная зависимость-пакет

EXPOSES / CONSUMES / COMMUNICATES_WITH

межъязыковые границы

Структурные рёбра (CONTAINS, DECLARES, IMPORTS, DEPENDS_ON) приходят прямо из парсинга и присутствуют всегда. Разрешённые (CALLS, REFERENCES, INHERITS_FROM, HAS_TYPE) приходят от резолвера, и их полнота зависит от статуса резолвера.

Детерминированные ID

ID узла — это SHA-256 от project::kind::qualified_name:

from graphlens import make_node_idmake_node_id("my-project", "my.module.func", "FUNCTION")# → один и тот же id при каждом скане, на любой машине

Поскольку ID зависит только от идентичности, а не от позиции в файле, повторное сканирование даёт те же самые ID. Именно это делает работоспособными graph.diff(other) и инкрементальные обновления — и позволяет кэшировать граф в CI.

Фича, которой не может быть у моноязычных инструментов: межъязыковые границы

Моя любимая часть. Адаптеры эмитят языко-независимые узлы BOUNDARY для интерфейсов, которые сервис предоставляет или потребляет — HTTP-маршруты, топики очередей, gRPC-методы, Temporal-активности — с ребром EXPOSES (провайдер) или CONSUMES (потребитель).

ID границы — это make_boundary_id(mechanism, key), и в нём нет ни проекта, ни языка. HTTP-пути нормализуются так, что /users/1, /users/{user_id} (FastAPI), <int:id> (Flask) и :id (Express) схлопываются в один ключ GET /users/{}.

Результат: маршрут FastAPI на Python и fetch на TypeScript к тому же эндпоинту дают одинаковый boundary-ID. Сливаем два графа, запускаем graphlens-link — и получаем рёбра COMMUNICATES_WITH, перешагивающие через языковую границу:

from graphlens import adapter_registryfrom graphlens_link import link_graphpy = adapter_registry.load("python")().analyze(python_project)ts = adapter_registry.load("typescript")().analyze(typescript_project)merged = pymerged.merge(ts, allow_shared=True)   # одинаковые BOUNDARY-узлы совпадаютresult = link_graph(merged)           # добавляет рёбра потребитель → провайдерprint(result.relations_added, "рёбер COMMUNICATES_WITH добавлено")

Теперь можно ответить на вопрос «какие вызовы фронтенда бьют в этот эндпоинт?» — вопрос, который моноязычный инструмент даже не способен сформулировать.

Пять способов использования

Как библиотека — загрузить адаптер, получить GraphLens, запрашивать: callers, callees, references, окрестности (neighbors), диффы, round-trip в JSON, слияние графов разных языков.

Из CLI — пять подкоманд покрывают типовые сценарии:

graphlens analyze ./repo --output graph.json   # индексацияgraphlens query process_order -g graph.json --op callersgraphlens visualize ./repo                      # интерактивный HTML на vis.jsgraphlens neo4j ./repo --uri bolt://localhost:7687graphlens mcp --graph graph.json                # отдать агентам

В CI--strict плюс Docker-образ (ghcr.io/neko1313/graphlens) со всеми адаптерами и тулчейнами внутри. Индексируем на каждый push, публикуем граф как артефакт, роняем сборку на деградировавшем графе.

LLM-агентам через MCPgraphlens mcp выставляет сохранённый граф как набор инструментов Model Context Protocol (stats, find, callers, callees, references, neighbors, boundaries, communicates_with). Вместо вываливания кодовой базы в промпт агент задаёт точные вопросы и получает маленькие структурированные ответы — разрешённые рёбра, а не текстовый поиск наугад. Это прямой ответ на боль «агент жжёт токены, бегая grep’ом по репозиторию».

Как экспорт в Neo4j — прямо в графовую БД через UNWIND … MERGE (без APOC), а дальше запрашивайте как угодно.

Плагинная архитектура: паттерн «диалектов SQLAlchemy»

Ядро никогда не импортирует адаптер. Каждый язык — отдельный пакет, который регистрирует себя через Python entry points:

[project.entry-points."graphlens.adapters"]python = "graphlens_python:PythonAdapter"

Вызывающий код находит адаптеры через реестр, по строковому имени:

adapter_registry.available()        # ['python', 'typescript', ...]adapter = adapter_registry.load("python")()

Добавить новый язык — значит написать один пакет под контракт LanguageAdapter. Ядро при этом не меняется.

Чем graphlens сознательно не является

Область применения намеренно узкая, и документация это явно фиксирует. graphlens производит граф-IR и на этом останавливается. Он не:

  • хранит состояние и не владеет базой данных (бэкенды — отдельный потребляющий слой);

  • следит за файловой системой и не переиндексируется инкрементально сам по себе (скан — чистая функция от дерева исходников; детерминированные ID позволяют инкрементальность, но управляет ей вызывающий код);

  • считает эмбеддинги, семантический поиск или ранжирование релевантности (граф структурный и type-aware, а не векторный индекс);

  • предоставляет UI или runtime для агента (visualize отдаёт статический HTML, mcp выставляет инструменты-запросы — ни то, ни другое не поднимает долгоживущий сервис).

Всё это — задача инструментов, построенных поверх graphlens. Минимальное ядро — это и есть то, что делает его композируемым.

Если сравнивать с готовыми «всё-в-одном» продуктами (вроде codegraph), разница именно в слое: graphlens — это движок и точная мультиязычная модель графа с разделёнными бэкендами, а не законченное приложение с собственным хранилищем и file-watcher’ом. На таком движке как раз и удобно строить подобные продукты.

Бенчмарки

Пропускная способность на реальных проектах, обновляется на каждом релизе внутри опубликованного Docker-образа (один холодный прогон, ориентировочно):

Проект

Язык

LOC

Узлов

Время

Разрешено

apache/superset

python

399 519

156 251

148.7s

84%

colinhacks/zod

typescript

74 194

8 741

19.0s

91%

gin-gonic/gin

go

23 672

7 227

13.9s

100%

gohugoio/hugo

go

224 821

34 809

112.7s

99%

BurntSushi/ripgrep

rust

50 275

9 612

113.1s

99%

Попробовать

pip install "graphlens-cli[python]"graphlens analyze . --output graph.jsongraphlens visualize .

Если вам когда-нибудь хотелось получить единую, точную, языко-независимую модель того, «как на самом деле устроена эта кодовая база», — graphlens отдаёт ровно это. Буду рад обратной связи, issue и контрибьюшенам адаптеров.

ссылка на оригинал статьи https://habr.com/ru/articles/1050204/