Любой инструмент для «понимания кода», которым я пользовался, рано или поздно упирался в одну из двух стен.
Первая — цикл «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 |
Типизированные узлы + направленные связи — промежуточное представление |
Graph Backend |
Хранит или запрашивает граф (Neo4j, in-memory, ваш собственный) |
Ключевое архитектурное решение: адаптеры — чистые продюсеры данных. Они никогда не пишут в базу, не трогают файловую систему после чтения, не поднимают сервер. Граф — единственный выход. Благодаря этому весь пайплайн тривиально тестируется, кэшируется и сериализуется.
Первый граф за 30 секунд
pip install "graphlens-cli[python]" graphlens analyze ./my-project
graphlens · my-project nodes: 1240 relations: 3981 resolver: ok nodes 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 Path from graphlens import adapter_registry adapter = 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 разделяет работу на два этапа:
Tree-sitter парсит каждый файл в конкретное синтаксическое дерево (CST), даёт точную структуру и 1-based позиции спанов. Каждый use-site он фиксирует как occurrence с ролью (вызов / чтение / запись / аннотация / базовый класс).
Затем type-aware резолвер, специфичный для языка, отвечает на
definition_at(file, line, col)для каждого occurrence. Разрешённое определение становится настоящим ребром к реальному узлу-декларации.
Язык |
Резолвер |
Движок |
|---|---|---|
Python |
|
|
TypeScript |
|
TypeScript Compiler API (Node-субпроцесс) |
Go |
|
|
Rust |
|
В итоге ребро CALLS указывает на реальную функцию, HAS_TYPE — на реальный класс, INHERITS_FROM — на реальный базовый класс. Это разница между «вероятно связано» и «связано».
Честность по поводу частичных сбоев
Типовой анализ может деградировать — тулчейн отсутствует, файл не проходит проверку типов. Вместо тихой выдачи наполовину разрешённого графа graphlens записывает результат:
from graphlens import RESOLVER_STATUS_KEY graph.metadata[RESOLVER_STATUS_KEY] # 'ok' | 'degraded' | 'unavailable'
Статус |
Значение |
|---|---|
|
type-aware слой отработал до конца |
|
резолвер запустился, но часть запросов упала |
|
резолвер вообще не стартовал (например, нет тулчейна) |
В CI включается --strict — и любой статус, кроме ok, роняет сборку. Так агент или дашборд никогда не получит граф, который незаметно неполон.
Модель графа
Узлы (PROJECT, MODULE, FILE, CLASS, METHOD, FUNCTION, PARAMETER, VARIABLE, ATTRIBUTE, TYPE_ALIAS, IMPORT, DEPENDENCY, EXTERNAL_SYMBOL, BOUNDARY) — это frozen-dataclass’ы с id, видом (kind), квалифицированным именем, путём к файлу, спаном и произвольными метаданными.
Связи — направленные типизированные рёбра:
Вид |
Смысл |
|---|---|
|
структурная вложенность и декларация |
|
операторы импорта и куда они разрешаются |
|
разрешённые type-aware рёбра |
|
объявленная зависимость-пакет |
|
межъязыковые границы |
Структурные рёбра (CONTAINS, DECLARES, IMPORTS, DEPENDS_ON) приходят прямо из парсинга и присутствуют всегда. Разрешённые (CALLS, REFERENCES, INHERITS_FROM, HAS_TYPE) приходят от резолвера, и их полнота зависит от статуса резолвера.
Детерминированные ID
ID узла — это SHA-256 от project::kind::qualified_name:
from graphlens import make_node_id make_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_registry from graphlens_link import link_graph py = adapter_registry.load("python")().analyze(python_project) ts = adapter_registry.load("typescript")().analyze(typescript_project) merged = py merged.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 callers graphlens visualize ./repo # интерактивный HTML на vis.js graphlens neo4j ./repo --uri bolt://localhost:7687 graphlens mcp --graph graph.json # отдать агентам
В CI — --strict плюс Docker-образ (ghcr.io/neko1313/graphlens) со всеми адаптерами и тулчейнами внутри. Индексируем на каждый push, публикуем граф как артефакт, роняем сборку на деградировавшем графе.
LLM-агентам через MCP — graphlens 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.json graphlens visualize .
Репозиторий: https://github.com/Neko1313/graphlens
Документация: https://Neko1313.github.io/graphlens/
Требования: Python 3.13+. Тулчейны для Python (
ty) и TypeScript (Node) ставятся по требованию; адаптеры Go и Rust удобнее всего получить через Docker-образ.
Если вам когда-нибудь хотелось получить единую, точную, языко-независимую модель того, «как на самом деле устроена эта кодовая база», — graphlens отдаёт ровно это. Буду рад обратной связи, issue и контрибьюшенам адаптеров.
crazyrock
Существует альтернатива - graphify, и там звёзд побольше будет. На первый взгляд эти инструменты решают одну и ту же задачу. Есть ли какие-либо преимущества перед конкурентом?
Neko1313 Автор
Graphify — это Claude Code skill для конечного пользователя, graphlens — движок. По резолвингу: graphify делает name-based matching через AST + LLM-угадывание, сами помечают это как INFERRED. graphlens гоняет реальные тайп-чекеры (ty/gopls/rust-analyzer/tsc) — ребро CALLS указывает на конкретный объявленный узел, не на строку с именем. Это разные инструменты для разных задач, сравнение по звёздам бессмысленно.
crazyrock
Благодарю. Жаль, что Ваш продукт так ограничен в выборе языка
Neko1313 Автор
Тут скорее пока что базовые языки, которые чаще других встречаю в своем стеке, но в целом их можно расширить, проект модульный, также в целом я в статье писал, как можно написать свой диалект
Tim02
Хотелось бы в php попробовать
Genius_Russian_Coders
Adapter → IR → backend — грамотно. А как с динамическими вызовами в Python (getattr, importlib)? Tree-sitter их не ловит, и граф вызовов будет неполным для реальных проектов.
Neko1313 Автор
Справедливое замечание.
getattr/importlib— это known limitation любого статического call graph: без runtime информации такие вызовы в принципе не разрешимы полностью. В текущей реализации они либо не формируютCALLS-рёбра вообще, либо резолвятся вEXTERNAL_SYMBOL(unknown)— граф деградирует в этих местах, но не ломается (resolver_statusвсё равно будетok, что технически не совсем честно для таких паттернов).На
ty-стороне пока не исследовал, насколько он это покрывает — он pre-1.0 и поведение нестабильно. Pyright теоретически чуть лучше сgetattr(obj, name)где типobjизвестен, но это: а) не универсально, б) существенно медленнее индексация. Скорее всего трекну это как известное ограничение и буду ждатьty1.0, где type inference должен быть значительно зрелее.