Головна / Статті / Чому ваша кодова база — це граф: парсинг Tree-sitter для ШІ-асистента з коду

Чому ваша кодова база — це граф: парсинг Tree-sitter для ШІ-асистента з коду

Чому ШІ-асистенти не читають увесь репозиторій, як графи й синтаксичні дерева передають структуру і як розібрати код через Tree-sitter, щоб побудувати індекс символів.

1386 слів

Код — це граф, а не купа тексту

Цей момент знайомий кожному, хто приходив на новий проєкт. Клонуєш репозиторій і думаєш, де ж живе логіка повторних спроб платежу. Рука сама тягнеться до одного з варіантів:

Ctrl + Shift + F

або

grep -r "retry" .

Іноді цього досить, але часто ні. Код повторів може ховатися за інтерфейсом, в іншому модулі, всередині декоратора, у базовому класі чи за патерном «стратегія». Щоб його знайти, замало збігу рядків: усе залежить від того, як частини пов'язані між собою.

А тепер спитайте Cursor «Як працюють повторні спроби платежу?», і за кілька секунд він підсвітить кілька файлів, пояснить хід виконання й навіть запропонує покращення. Схоже на магію. Дивно тут те, що він не читає весь ваш репозиторій — і не зміг би.

Поширена хибна модель

Більшість розробників спершу уявляють роботу інструмента так:

Repository
      │
      ▼
Large Language Model
      │
      ▼
Answer

Звучить розумно, але працювати не може. Візьмімо кодову базу середнього розміру:

12,000 Python files
≈ 2 million lines of cod

Навіть моделі з дуже великим контекстним вікном не можуть ефективно обробляти весь репозиторій на кожен запит, а якби й могли, це коштувало б дорого. Надсилання кожного вихідника, README, конфігу, тесту й згенерованого файлу означало б жахливу затримку й величезний рахунок. Гірше того, майже всі ці токени були б не до справи. Якщо питання про повтори платежів, моделі нема чого читати код обробки зображень.

Ви ж теж читаєте не все

Подивіться, як шукає досвідчений інженер. Якщо його спитати, де реалізовано автентифікацію, ніхто не відкриває main.py і не читає файли по черзі. У голові одразу складається мапа, приблизно така:

Request
   │
   ▼
API Router
   │
   ▼
Authentication Middleware
   │
   ▼
JWT Validator
   │
   ▼
User Service

Ви йдете за зв'язками, тобто обходите граф. Різниця величезна. Досвідчені інженери не запам'ятовують мільйони рядків. Вони пам'ятають зв'язки.

Чим насправді є репозиторій

Репозиторій — не мішок із файлами. Це мережа пов'язаних символів. Візьмімо невеликий проєкт:

src/
├── api.py
├── payment.py
├── retry.py
├── queue.py
└── logger.py

На перший погляд це п'ять не пов'язаних файлів. Та якщо намалювати зв'язки, картина інша:

              api.py
                │
                ▼
         payment.py
           │     │
           ▼     ▼
      retry.py  logger.py
           │
           ▼
        queue.py

Ця схема пояснює більше, ніж будь-яке дерево каталогів. Кожен виклик функції, імпорт, успадкування, реалізація інтерфейсу й залежність — це ребро графа. З такого погляду ШІ-асистенти стають зрозумілішими: вони пересуваються зв'язками, а не просто шукають по файлах.

Де текстовий пошук ламається

Візьмімо маленький приклад:

class PaymentProcessor:
def charge(self, order):
        return self.gateway.execute(order)

Де насправді відбувається платіж? Незрозуміло. gateway може виявитися Stripe, Razorpay, PayPal, моком чи тестовим дублером, і пошук за «charge» цього не прояснить. Додайте впровадження залежностей, і стане ще гірше:

processor = container.resolve(PaymentProcessor)

Тепер реалізацію обирають під час виконання, і текстовий пошук майже марний. Сучасна розробка сильно спирається на абстракції, і, за іронією, саме абстракції б'ють по наївному пошуку.

Код має структуру

Мови програмування — не простий текст. Вони структуровані. Візьмімо таку функцію:

def calculate_total(price, tax):
    return price + tax

Ви бачите символи, а компілятор бачить структуру, приблизно таку:

FunctionDefinition
├── Name
│     └── calculate_total
├── Parameters
│     ├── price
│     └── tax
└── Return
      └── BinaryExpression(+)

Таке подання називають абстрактним синтаксичним деревом, або AST. Воно зберігає зміст і відкидає форматування. Ці два варіанти запису:

return price + tax

і

return (
    price
    +
    tax
)

дають по суті однакове AST. Саме тому сучасні асистенти з коду починають із розбору вихідників, а не працюють із ними як із сирим текстом.

Tree-sitter

На Tree-sitter тримається багато інструментів розробника. Замість того щоб сканувати файл рядок за рядком, він будує синтаксичне дерево, яке розуміє мову. Візьмімо такий код:

class PaymentService:
def retry_payment(self):
        pass

Tree-sitter бачить не два рядки тексту, а щось ближче до цього:

Module
│
├── Class
│     └── PaymentService
│
└── Function
      └── retry_payment

Ця структура окупається згодом: з нею можна відповісти, якому класу належить метод, які функції публічні, які методи викликають певну функцію і які символи експортуються. Текстовий пошук ніколи не відповість на це надійно.

Розбір репозиторію

Час зібрати першу частину асистента. Встановлюємо залежності:

pip install tree-sitter tree-sitter-python

Потім розбираємо файл:

from tree_sitter import Language, Parser
import tree_sitter_python as tspython

parser = Parser()
PY_LANGUAGE = Language(tspython.language())
parser.language = PY_LANGUAGE
code = """
class PaymentService:
    def retry_payment(self):
        print("Retrying...")
"""
tree = parser.parse(bytes(code, "utf8"))
print(tree.root_node)

Виведення виглядає так:

(module
    (class_definition
        (identifier)
        (block
            (function_definition))))

Не гарно, зате корисно. Видно, де починається кожен клас, де закінчується кожна функція, де стоять імпорти й де оголошено декоратори, і нікому не довелося писати парсер вручну.

Дістаємо всі функції

Далі обходимо синтаксичне дерево й збираємо всі функції:

from tree_sitter import Parser, Language
import tree_sitter_python as tspython


parser = Parser()
parser.language = Language(tspython.language())

def extract_functions(node, source):
    if node.type == "function_definition":
        name = node.child_by_field_name("name")
        print(source[name.start_byte:name.end_byte].decode())
    for child in node.children:
        extract_functions(child, source)

source = open("payment.py", "rb").read()
tree = parser.parse(source)
extract_functions(tree.root_node, source)

Виведення:

retry_payment
refund
calculate_total

Зверніть увагу, що сталося. Код не шукав ключове слово def. Він попросив у синтаксичного дерева кожне визначення функції, і це суттєва різниця.

Будуємо індекс

Замість того щоб зберігати файли цілком, створіть структурований індекс із такими записами:

{
  "symbol": "retry_payment",
  "type": "function",
  "file": "payment.py",
  "class": "PaymentService",
  "start_line": 42,
  "end_line": 67
}

Зробіть це для кожної функції, класу, інтерфейсу, переліку й константи, і неструктурований репозиторій перетвориться на знання, якими можна шукати.

Що вже маємо

Конвеєр, зібраний до цього моменту, виглядає так:

                Git Repository
                       │
                       ▼
              Repository Scanner
                       │
                       ▼
                Tree-sitter Parser
                       │
                       ▼
             Abstract Syntax Tree
                       │
          ┌────────────┴────────────┐
          ▼                         ▼
     Function Index           Class Index
          │                         │
          └────────────┬────────────┘
                       ▼
                Repository Graph

На ШІ-систему він поки не схожий, бо нею не є. Це конвеєр видобування знань, і з нього починається будь-який сучасний ШІ-асистент з коду.

Чому починати саме звідси

Багато туторіалів одразу стрибають до ембедингів і векторних баз. Це як будувати Google, поки не існує жодної вебсторінки. Те, наскільки добре асистент розуміє репозиторій ще до будь-якого машинного навчання, значною мірою і визначає його якість. Якщо індекс поганий, жоден промпт-інжиніринг його не врятує. Сміття на вході — сміття на виході.

Що далі

На цьому етапі асистент знає, де лежить кожна функція й кожен клас, але не розуміє змісту. Наступне завдання — розбиття на чанки. Більшість розробників ріжуть репозиторії на довільні блоки по 500 чи 1000 токенів. Звучить розумно, але це один із найшвидших способів отримати посереднього асистента. Краще семантичне розбиття: за функціями, класами, символами й логічними межами, щоб пошук ішов так, як про код думає досвідчений інженер. У результаті маємо індекс репозиторію з осмислених одиниць, готовий до ембедингів, векторного пошуку й розумнішого видобування.