Любой инструмент для «понимания кода», которым я пользовался, рано или поздно упирался в одну из двух стен.
Первая — цикл «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: 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 разделяет работу на два этапа:
-
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_KEYgraph.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_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-агентам через 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.jsongraphlens visualize .
-
Репозиторий: https://github.com/Neko1313/graphlens
-
Документация: https://Neko1313.github.io/graphlens/
-
Требования: Python 3.13+. Тулчейны для Python (
ty) и TypeScript (Node) ставятся по требованию; адаптеры Go и Rust удобнее всего получить через Docker-образ.
Если вам когда-нибудь хотелось получить единую, точную, языко-независимую модель того, «как на самом деле устроена эта кодовая база», — graphlens отдаёт ровно это. Буду рад обратной связи, issue и контрибьюшенам адаптеров.
ссылка на оригинал статьи https://habr.com/ru/articles/1050204/