Любой инструмент для «понимания кода», которым я пользовался, рано или поздно упирался в одну из двух стен.
Первая — цикл «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 и контрибьюшенам адаптеров.
