# xref: индекс кода, которому можно верить

**Спецификация, а не изложение идеи.** Ниже всё, что нужно, чтобы собрать
инструмент и получить работающий результат без круга «не работает — чиним»:
полная схема хранения, контракты записей, рабочий код тех разборов, где ошибиться
легче всего, и список проверок, которыми результат удостоверяется.

Что получается: локальная база с фактами о коде и о средах плюс обёртка
`bin/xref` над набором задач. Ни демона, ни службы, ни внешнего сервиса. Около
шести тысяч строк на одном языке, из зависимостей — только парсер SQL вашей базы.
Полный разбор дерева в 8 тысяч файлов — минута, догон после правки — доли
секунды.

Стек в примерах: Ruby/Rails + PostgreSQL. Приёмы от платформы не зависят; места,
где важна именно она, помечены. Код приведён рабочим, а не иллюстративным: он
взят из работающего инструмента и обезличен.

**Приведённое проверено исполнением, а не вычиткой.** Вся схема из части III и
все представления из части V накатываются на чистую базу без единой ошибки
(18 таблиц, 21 представление, каждое отвечает на `select`); все тринадцать
фрагментов кода проходят проверку синтаксиса; программа разбора логов проверена
на образце и даёт оба счётчика. Если у вас что-то не накатилось — дело в версии
базы, и об этом стоит сказать, а не обходить.

Писалось по опыту приложения в 600 тысяч строк, 8 тысяч файлов, двадцать лет
истории, запросы к базе строками, пять живых экземпляров базы со разъехавшимися
схемами. Если ваш проект моложе и весь на ORM — нужны слои 1–3, остальное по
потребности.

---

# Часть I. Зачем

Поиск по тексту и «найти использования» отвечают не на тот вопрос. Три причины,
каждая стоила инцидента.

**Имя — плохой ключ.** Короткое имя колонки одновременно оказывается именем
сервера в инфраструктуре и самым популярным именем локальной переменной. Поиск
даёт три сотни совпадений, из которых к базе относится шесть.

**Запрос живёт в строке, а не в коде.** Там, где SQL написан текстом, среда
разработки не знает, что строка трогает таблицу. Для неё это просто текст. А
дальше в этой строке стоит подстановка, и имя таблицы собирается на ходу.

**Среды разъехались, и из кода это не видно.** Если живых баз больше одной, рано
или поздно таблица окажется только в части из них. Код выглядит безупречно и
падает только там, где не хватает. Это худший класс ошибок: он молчит до боя.

---

# Часть II. Что получается

```sh
bin/xref where users.email    # где трогают таблицу или колонку: разобранным
                              #   запросом, через ORM, «похоже по тексту» — врозь
bin/xref column recover_      # в каких таблицах и средах живёт колонка
bin/xref blind                # код ходит в таблицу, которой в этой среде нет
bin/xref drift                # чем схемы сред отличаются друг от друга
bin/xref holes [имя]          # что подставляется в запрос интерполяцией,
                              #   в какое место запроса и откуда берётся текст
bin/xref calls [метод]        # кто зовёт: из кода, из шаблона, через send,
                              #   по символу, по роуту
bin/xref callers <файл>       # кто ссылается на файл и кто рисовал его в бою
bin/xref dead                 # неиспользуемое: роуты, действия, контроллеры,
                              #   методы (VIS=public — ещё и публичные)
bin/xref changed [ref]        # что задето правкой: методы, их вызывающие,
                              #   таблицы и среды, где этих таблиц нет
bin/xref boundary <файл>      # что прочитать, прежде чем править этот файл
bin/xref rules                # архитектурные правила и их защёлки
bin/xref views                # какие шаблоны и действия работали в бою
bin/xref audit                # аудит самого индекса: связность, шум, разрывы
bin/xref refresh              # обновить схемы, факты приложения, код
```

Обёртка — тридцать строк на `sh`, которые зовут задачи сборщика
(`xref:schema`, `xref:code`, `xref:app`, …). Внутри задач — обычный SQL по базе
индекса.

---

# Часть III. Контракты

Три вещи, которые надо зафиксировать ДО написания кода. Если их не зафиксировать,
переписывать придётся всё.

## III.1. Схема хранения

Полностью. Это PostgreSQL; на другой базе поменяются только типы массивов и
`jsonb`.

```sql
-- Источники схем: среды, дамп из репозитория, колоночная база.
create table if not exists sources (
  id         serial primary key,
  name       text not null unique,
  kind       text not null,          -- 'pg' | 'hbase' | …: разводит пространства имён
  transport  text not null,          -- как снимать: activerecord | ssh_psql | structure_sql | thrift
  detail     jsonb not null default '{}'::jsonb
);

-- Снимок одного источника. Храним историю: видно, КОГДА колонка появилась.
create table if not exists snapshots (
  id             serial primary key,
  source_id      integer not null references sources(id) on delete cascade,
  taken_at       timestamptz not null default now(),
  git_sha        text,                -- на каком коде снимали
  server_version text,
  database_name  text,
  ok             boolean not null default true,
  error          text,                -- неудачный снимок тоже ряд: видно, что источник отвалился
  warnings       text[] not null default '{}',
  duration_ms    integer
);
create index if not exists idx_snapshots_source on snapshots (source_id, taken_at desc);

create table if not exists db_tables (
  snapshot_id integer not null references snapshots(id) on delete cascade,
  name        text not null,
  kind        text not null,          -- r | p | v | m | f: таблица, секция, вьюха, матвьюха, сторонняя
  est_rows    bigint,
  primary key (snapshot_id, name)
);

create table if not exists db_columns (
  snapshot_id  integer not null references snapshots(id) on delete cascade,
  table_name   text not null,
  column_name  text not null,
  ordinal      integer not null,
  data_type    text not null,
  not_null     boolean not null default false,
  default_expr text,
  primary key (snapshot_id, table_name, column_name)
);
create index if not exists idx_db_columns_name on db_columns (column_name);

create table if not exists db_indexes (
  snapshot_id integer not null references snapshots(id) on delete cascade,
  table_name  text not null,
  index_name  text not null,
  is_unique   boolean not null default false,
  is_primary  boolean not null default false,
  definition  text not null,
  primary key (snapshot_id, table_name, index_name)
);

-- Разбор кода. `sha` — ключ инкрементальности.
create table if not exists code_files (
  path       text primary key,
  sha        text not null,
  lang       text not null,           -- ruby | view | js | sql | other
  bytes      integer,
  refs_count integer not null default 0,
  git_sha    text,
  indexed_at timestamptz not null default now()
);

-- Главная таблица. Одна строка на (файл, вид записи, имя, владелец) — НЕ на
-- каждое вхождение: иначе популярное имя даст сотни тысяч строк и ровно столько
-- же пользы, сколько одна. Состав `detail` — в III.2.
create table if not exists code_refs (
  path   text not null references code_files(path) on delete cascade,
  kind   text not null,
  token  text not null,
  owner  text not null default '',
  count  integer not null default 1,
  lines  integer[] not null default '{}',   -- первые 20 строк вхождения
  detail jsonb,
  primary key (path, kind, token, owner)
);
create index if not exists idx_code_refs_token on code_refs (token);
create index if not exists idx_code_refs_kind on code_refs (kind, token);
create index if not exists idx_code_refs_owner on code_refs (owner) where owner <> '';

-- Факты у запущенного приложения. Обновляются целиком, истории не имеют: это
-- снимок текущего кода, а не внешнего источника.
create table if not exists app_models (
  class_name  text primary key,
  table_name  text,
  path        text,
  primary_key text,
  database    text,                   -- к какому подключению приписана модель
  base        text                    -- ближайший абстрактный предок
);
create index if not exists idx_app_models_table on app_models (table_name);

create table if not exists app_associations (
  class_name  text not null,
  name        text not null,
  macro       text not null,          -- has_many | belongs_to | …
  target      text,                   -- класс на другом конце
  foreign_key text,
  primary key (class_name, name)
);

create table if not exists app_routes (
  verb             text not null default '',
  path             text not null,
  controller       text not null default '',
  action           text not null default '',
  controller_class text,
  route_name       text,
  primary key (verb, path, controller, action)
);

create table if not exists app_controllers (
  class_name text primary key,
  path       text,
  actions    text[] not null default '{}'   -- публичные методы, пригодные в роли действия
);

create table if not exists app_jobs (
  job_key    text not null,
  class_name text not null,
  cron       text,
  places     text[] not null default '{}',  -- в каких средах работа включена ('*' — везде)
  path       text,
  primary key (job_key, class_name, cron)
);

-- Карта «константа -> файл» от автозагрузчика. По ней ссылка на класс
-- превращается в ребро графа «файл -> файл» — бесплатно и точно.
create table if not exists app_constants (
  constant text primary key,
  path     text not null
);
create index if not exists idx_app_constants_path on app_constants (path);

-- Где код выполняется: среды и состав служб, снятый с живых контейнеров.
create table if not exists run_places (
  place     text primary key,
  env_id    text not null,            -- чем среда опознаётся внутри приложения
  ssh_host  text,
  container text
);

create table if not exists run_units (
  place     text not null,
  unit      text not null,
  command   text,
  task      text,                     -- если служба запускает задачу сборщика — её имя
  taken_at  timestamptz not null default now(),
  primary key (place, unit)
);

-- Ключи, реально встреченные внутри бесхемных полей. Не схема, а выборка.
create table if not exists json_keys (
  source      text not null,
  table_name  text not null,
  column_name text not null,
  key         text not null,
  hits        integer not null default 0,
  sampled_at  timestamptz not null default now(),
  primary key (source, table_name, column_name, key)
);
create index if not exists idx_json_keys_key on json_keys (key);

-- Что рисовалось в бою: шаблон и действие, из которого он нарисован.
create table if not exists view_runtime (
  place    text not null,
  host     text not null,
  day      date not null,
  action   text not null default '',
  template text not null,
  path     text,                      -- шаблон, приведённый к пути в репозитории
  hits     integer not null default 0,
  primary key (place, host, day, action, template)
);
create index if not exists idx_view_runtime_path on view_runtime (path);

-- Какие действия ЗАПРАШИВАЛИ. Отдельно от отрисовок: действие, отвечающее
-- переходом или json-ом, шаблонов не рисует и по view_runtime неотличимо от
-- мёртвого. Без этой таблицы корень сайта попадает в мёртвые.
create table if not exists action_runtime (
  place  text not null,
  host   text not null,
  day    date not null,
  action text not null,
  hits   integer not null default 0,
  primary key (place, host, day, action)
);
create index if not exists idx_action_runtime_action on action_runtime (action);
```

**Правила обращения с этой схемой** — четыре, и все четыре выстраданы:

1. **DDL накатывается на каждом запуске и идемпотентен**: `create table if not
   exists`, `alter table … add column if not exists`. Миграций у инструмента
   анализа быть не должно: он обязан переживать переключение ветки в обе стороны.
2. **Представления сносятся все и создаются заново**, одним блоком перед их
   созданием. `create or replace view` не умеет менять состав и порядок колонок,
   а они будут меняться постоянно. Снос — `drop view if exists … cascade` в
   порядке, обратном зависимостям.
3. **Блокировка вокруг накатывания** (`pg_advisory_lock` с фиксированным
   ключом): в одном дереве работают несколько процессов, и два одновременных
   сноса представлений дают гонку.
4. **Отдельное подключение** от приложения — чтобы ничего не подмешивать в его
   пул и его базу. В Rails это абстрактный класс с `establish_connection`.

## III.2. Виды записей `code_refs`

Главный контракт инструмента: по нему разборщик и отчёты сходятся. Если он не
зафиксирован, соединения не сойдутся и отчёты начнут врать.

Правило, которому подчинён весь список: **надёжность записи — часть записи.**
Факт, догадка и совпадение по тексту не смешиваются ни в одном отчёте.

| `kind` | `token` | `owner` | `detail` | Надёжность |
| --- | --- | --- | --- | --- |
| `pg_table` | имя таблицы | `''` | — | **факт**: SQL разобран парсером базы |
| `pg_column` | имя колонки | таблица или `''`, если не определилась | — | **факт** |
| `ar_column` | имя колонки | таблица модели или `''` | — | факт, если корень цепочки — класс модели |
| `json_key` | ключ | `таблица.колонка` (неизвестная часть — `?`) | — | **факт** |
| `def` | имя метода | класс-владелец или `''` | `sql`, `returns`, `returns_part`, `singleton`, `visibility`, `params[]` | **факт** |
| `assign` | имя переменной | область (`Класс#метод`) | `value`, `sql`, `part`, `op`, `literal`, `carrier`, `from`, `const`, `holes[]`, `more[]` | **факт** |
| `sql_hole` | корень выражения | место в запросе (III.2.1) | `expr`, `scope`, `scopes[]`, `blind`, `fragment`, `carrier`, `call`, `const`, `sql` | **факт о подстановке** |
| `const_ref` | имя константы | область (для задач сборщика — имя задачи) | — | факт ссылки, не вызова |
| `view_name` | логическое имя шаблона | `''` | — | факт |
| `render_ref` | имя шаблона | `''` | — | **факт**: имя стоит в коде целиком |
| `render_dynamic` | узор или выражение | `skeleton`, если узор есть; иначе `''` | `glob`, `source`, `expr`, `root`, `scope` | **кандидат** либо честное «не знаю» |
| `task_entry` | имя задачи сборщика | `''` | — | факт |
| `hb_table` / `hb_column` | таблица / `семейство:колонка` | `''` / таблица | — | факт (литералы в вызовах) |
| `pg_table_guess` | имя после `from`/`join` | `''` | — | **догадка**: SQL не разобрался |
| `token` | имя, известное базе, встреченное в тексте | `''` | — | **кандидат** |
| `sql_blind` | начало неразобранного запроса | `''` | `error` | **слепое место** |
| `hb_dynamic` | имя обёртки или `колонка X` | таблица или `''` | `expr`, `skeleton`, `scope` | **слепое место** |
| `scan_error` | класс ошибки | `''` | `message` | **слепое место** |

### III.2.1. Места подстановки

`owner` у `sql_hole` — место в запросе. Перечень закрытый; порядок определения
важен (IV.3).

| Значение | Что значит | Насколько опасно |
| --- | --- | --- |
| `table` | подставляется имя таблицы | индекс не знает, какую таблицу трогает код |
| `glue` | подстановка приросла к имени | то же, плюс шарды |
| `in_list` | внутри `in (…)` | может прийти целый подзапрос |
| `condition` | целое условие после `where`/`and`/`or`/`on`/`having` | смысл запроса задаёт не эта строка |
| `order_by`, `group_by` | порядок или группировка | подставляется имя колонки |
| `set` | присваивание в `update` | то же |
| `select_list` | список выбираемых полей | то же |
| `values` | внутри `values (…)` | значение |
| `value` | справа от сравнения | значение |
| `limit` | предел выборки | значение |
| `quoted` | внутри кавычек | значение |
| `other` | не распознано | смотреть глазами |

### III.2.2. Дедупликация и слияние

Ключ `(path, kind, token, owner)`. При повторном вхождении: `count += 1`, строка
добавляется в `lines` (до двадцати). `detail` берётся от первого вхождения, но
два поля накапливаются, иначе запись врёт:

- **`more`** — другие выражения или значения с тем же ключом. `ids.join(',')` и
  `ids.map(&:to_i).join(',')` — один корень, разный смысл;
- **`scopes`** — другие области. Одна и та же подстановка в трёх методах файла
  даёт один ряд, и `scope` от первого из них про остальные врёт.

```ruby
def add(kind, token, owner, line, detail: nil)
  token = token.to_s
  return if token.empty?

  ref = (@refs[[kind, token, owner.to_s]] ||= Ref.new(kind, token, owner.to_s, [], 0, detail))
  ref.count += 1
  ref.lines << line if ref.lines.size < MAX_LINES
  merge_detail(ref, detail) if detail && ref.detail && !ref.detail.equal?(detail)
end

def merge_detail(ref, detail)
  text = detail[:expr] || detail[:value]
  collect(ref.detail, :more, text) unless ref.detail[:expr] == text || ref.detail[:value] == text
  collect(ref.detail, :scopes, detail[:scope]) unless ref.detail[:scope] == detail[:scope]
end

def collect(store, key, text)
  return if text.blank?

  list = (store[key] ||= [])
  list << text if list.size < 4 && !list.include?(text)
end
```

## III.3. Конфигурация

Один файл — единственный источник правды об адресах, источниках и правилах.
Пример полный; имена сред условны.

```yaml
# Куда складывать индекс. Отдельная база, к приложению отношения не имеет.
store_url: <%= ENV.fetch('XREF_DATABASE_URL', 'postgresql:///xref') %>

# Схема снимается с каждого источника ОТДЕЛЬНО: «схемы вообще» не существует.
# transport:
#   activerecord  — подключением приложения (локальная база)
#   ssh_psql      — ssh <ssh_host> docker exec -i <container> psql …
#   structure_sql — дамп схемы, загруженный во временную базу
#   thrift        — колоночная база
sources:
  repo:
    kind: pg
    transport: structure_sql
    path: db/structure.sql
    scratch_database: xref_repo
    comment: схема из репозитория — такой же источник, как живые

  local:
    kind: pg
    transport: activerecord
    comment: база разработки

  prod_a:
    kind: pg
    transport: ssh_psql
    ssh_host: node-a1            # узел, где живёт ВЕДУЩАЯ база, не реплика
    container: pg_primary        # ОБРАЗЕЦ имени, не точное имя: см. IV.1
    user: postgres
    database: app
    comment: >-
      снимать только с ведущей: реплика отстаёт, и после миграции вы получите
      ложное «колонки нет»

  prod_b:
    kind: pg
    transport: ssh_psql
    ssh_host: node-b1
    container: pg_primary
    user: postgres
    database: app

  stage:
    kind: pg
    transport: ssh_psql
    ssh_host: stage-1
    container: app_runtime       # psql внутри контейнера приложения
    user: app
    database: app

# Откуда брать ключи внутри бесхемных полей. Схемы у них нет: есть только то,
# что реально лежит. Опрашивать НЕСКОЛЬКО сред и помечать источник — если данные
# в средах разные, ключ со стенда это след опытов, а не боевой факт.
json_sampling:
  sources: [prod_a, prod_b, stage]
  rows: 5000
  statement_timeout: 20s

# Боевые логи: где лежат и на каких узлах. DATE подставляется датой.
view_logs:
  places:
    prod_a:
      glob: '/var/log/app/DATE-web-*.log'
      hosts: [node-a1, node-a2, node-a3]
    prod_b:
      glob: '/var/log/app/DATE-web-*.log'
      hosts: [node-b1, node-b2]

# Где что выполняется. Состав служб снимается с живых контейнеров: инвентарь,
# который ведут руками, отстаёт всегда. Имена мест совпадают с именами
# источников схем — иначе отчёты не соединить.
run_places:
  prod_a:
    ssh_host: node-a1
    container_match: '^app_runtime'
    env_id: '2'
  prod_b:
    ssh_host: node-b1
    container_match: '^app_runtime'
    env_id: '3'

# Архитектурные правила: ребро графа из `from` в `to` — нарушение.
# `limit` — ЗАЩЁЛКА, а не норма: столько терпим сегодня. Отчёт молчит, пока
# число не выросло, и кричит на первом новом. Без защёлки правило с тремя
# сотнями старых нарушений перестают читать в первый же день.
rules:
  - name: миграция не должна знать про модели
    from: 'db/migrate/%'
    to: 'app/models/%'
    why: модель переименуют — реплей миграции упадёт посреди выкатки
    limit: 7
  - name: модель не должна тянуть контроллер
    from: 'app/models/%'
    to: 'app/controllers/%'
    why: обратная зависимость; из фонового процесса контроллера нет
    limit: 3
  - name: старый слой интерфейса не должен ссылаться на новый
    from: 'app/views/legacy/%'
    to: 'app/views/current/%'
    why: это не нарушение, а мера незаконченного переноса — число должно падать
    limit: 389
```

---

# Часть IV. Сбор

## IV.1. Схемы сред

**Один запрос к системному каталогу**, а не к переносимым представлениям: нужен
вид объекта, и один и тот же запрос обязан работать на всех версиях, какие у вас
живут. Отдаёт один документ JSON — его проще передать и сохранить.

```sql
select json_build_object(
  'server_version', current_setting('server_version'),
  'database', current_database(),
  'tables', coalesce((select json_agg(t) from (
      select c.relname as name, c.relkind::text as kind, c.reltuples::bigint as est_rows
      from pg_class c
      join pg_namespace n on n.oid = c.relnamespace
      where n.nspname = 'public' and c.relkind in ('r','p','v','m','f')
      order by c.relname) t), '[]'::json),
  'columns', coalesce((select json_agg(c) from (
      select cl.relname as table_name, a.attname as column_name, a.attnum as ordinal,
             format_type(a.atttypid, a.atttypmod) as data_type,
             a.attnotnull as not_null,
             pg_get_expr(d.adbin, d.adrelid) as default_expr
      from pg_attribute a
      join pg_class cl on cl.oid = a.attrelid
      join pg_namespace n on n.oid = cl.relnamespace
      left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
      where n.nspname = 'public' and a.attnum > 0 and not a.attisdropped
        and cl.relkind in ('r','p','v','m','f')
      order by cl.relname, a.attnum) c), '[]'::json),
  'indexes', coalesce((select json_agg(i) from (
      select t.relname as table_name, ir.relname as index_name,
             ix.indisunique as is_unique, ix.indisprimary as is_primary,
             pg_get_indexdef(ir.oid) as definition
      from pg_index ix
      join pg_class ir on ir.oid = ix.indexrelid
      join pg_class t on t.oid = ix.indrelid
      join pg_namespace n on n.oid = t.relnamespace
      where n.nspname = 'public'
      order by t.relname, ir.relname) i), '[]'::json)
)
```

**Транспорт до боя.** Прямого хода к базе обычно нет: контейнер публикует не тот
порт, а до самого порта базы с рабочей машины хода нет. Не изобретайте туннель —
зайдите по ssh и выполните клиент базы внутри контейнера, запрос передайте на
stdin:

```ruby
def ssh_psql(sql)
  out, err, status = Open3.capture3(
    'ssh', '-o', "ConnectTimeout=#{CONNECT_TIMEOUT}", '-o', 'BatchMode=yes',
    cfg[:ssh_host].to_s, remote_psql_script,
    stdin_data: sql
  )
  raise Error, "#{name}: ssh вернул #{status.exitstatus}: #{err.strip.lines.last(3).join(' ')}" unless status.success?

  out
end

# Имя контейнера в конфиге — ОБРАЗЕЦ, а не точное имя. В оркестраторе имя несёт
# хвост задачи (`pg_primary.1.new3ic22ln…`) и меняется при каждом перезапуске
# службы. Резолвим на той стороне. Пока этого не было, обновление схем молча
# отвалилось, и индекс неделю отвечал по старому снимку.
def remote_psql_script
  match = Shellwords.escape("^#{Regexp.escape(cfg[:container].to_s)}(\\.|$)")
  psql = Shellwords.join(['psql', '-U', cfg[:user].to_s, '-d', cfg[:database].to_s,
                          '-At', '-q', '-f', '-'])
  <<~SH
    c=$(docker ps --format '{{.Names}}' | grep -E #{match} | head -1)
    if [ -z "$c" ]; then echo "контейнер по образцу не найден" >&2; exit 1; fi
    exec docker exec -i "$c" #{psql}
  SH
end
```

**Дамп схемы из репозитория не парсите.** Загрузите его во временную базу и
снимите тем же запросом, что и живые. Иначе в проекте появится второй,
самодельный разборщик схемы, и он разойдётся с настоящим. Побочная выгода: дамп и
локальная база начинают проверять друг друга.

**Неудачный снимок — тоже ряд** в `snapshots` (`ok = false`, текст ошибки).
Иначе отвалившийся источник не отличить от источника, которого нет, и отчёты
начинают молча отвечать по старому снимку. Возраст снимка показывайте в отчётах о
расхождениях.

## IV.2. Разбор кода: инкрементальный цикл

Сто строк, которые меняют всё: индекс можно догонять **перед каждым запросом**, и
дисциплина «не забыть переиндексировать» исчезает.

```ruby
def scan_code(full: false)
  Store.migrate!
  known         = Store.known_names        # имена колонок и таблиц из снимков
  model_tables  = Store.model_tables       # Класс -> таблица
  app_constants = Store.app_constant_names # имена констант от автозагрузчика

  extensions = %w[.rb .rake .ru .erb .haml .slim .builder .jbuilder .js .jsx .mjs .sql]
  # Дампы схемы — это схема, а не код, который её трогает: в дампе лежат все
  # шардированные таблицы, и все они попадут в «код обращается к таблице».
  # Свои же файлы индекс не разбирает: его DDL — про другую базу.
  skip = %r{\A(vendor/|node_modules/|public/|db/[^/]+\.sql\z|lib/xref/|lib/tasks/xref\.rake\z)}

  files = `git ls-files -z`.split("\0")
          .select { |f| extensions.include?(File.extname(f)) }
          .reject { |f| f.match?(skip) }

  current = files.each_with_object({}) do |f, acc|
    acc[f] = Digest::SHA1.file(f).hexdigest if File.file?(f)
  end

  stored  = Store.code_file_shas
  # Исчезнувшие считаем ВСЕГДА, в том числе при полном пересборе: иначе остаются
  # ряды от файлов, которые из обхода выпали.
  removed = stored.keys - current.keys
  changed = full ? current : current.reject { |path, sha| stored[path] == sha }

  Store.delete_code_files(removed) if removed.any?
  changed.each do |path, sha|
    source = File.read(path, encoding: 'UTF-8').scrub('')
    refs = CodeScanner.new(path, source, known: known, model_tables: model_tables,
                                         app_constants: app_constants).scan
    Store.write_code_file(path, sha: sha, lang: CodeScanner.language(path),
                                bytes: File.size(path), refs: refs)
  end
end
```

Запись одного файла — удалить его записи и вставить новые **в одной транзакции**:
прерванный разбор не должен оставлять файл с половиной записей.

## IV.3. Разбор SQL: подстановка и место в запросе

Самая тонкая часть. Здесь легче всего получить инструмент, который выдумывает
имена таблиц, — и не заметить этого.

**Дыра — это `$1`, а не `1`.** С единицей строка `"… from shard_#{n}"`
разбирается как таблица `shard_1`: индекс молча выдумывает имена, которых нет.
`$1` в позиции значения законен, а в позиции имени ломает разбор — и место честно
уходит в слепые зоны. Одного `$1` недостаточно: в идентификаторах PostgreSQL
символ `$` допустим, поэтому отдельно ловится **прирастание дыры к имени**.

```ruby
SQL_HOLE = '$1'
QUESTION_PLACEHOLDER = /(?<=[\s,(=<>])\?(?=[\s,)]|\z)/   # `where id = ?` — тоже подстановка
SQL_START = /\A\s*(select\s|insert\s+into\s|update\s+[a-z_"]|delete\s+from\s|with\s+[a-z_"])/i
IDENT_HEAD = /\A[A-Za-z0-9_$]/
IDENT_TAIL = /[A-Za-z0-9_$]\z/

# Грубая проверка «мы внутри строкового литерала»: внутри кавычек склейка
# `'%$1%'` безобидна, а `tbl$1` снаружи — уже выдуманное имя.
def inside_string_literal?(text)
  text.count("'").odd?
end
```

**Сборка интерполированной строки.** Возвращает четыре значения: текст,
был ли интерполяция, приросла ли дыра к имени, и сами дыры со смещениями.

```ruby
# => [текст, была_ли_интерполяция, приросла_ли_дыра_к_имени, дыры]
# дыра: { expr: 'ids.join(",")', offset: 42, line: 17 }
def literal(node)
  case node
  when StringNode, SymbolNode
    [node.unescaped.scrub(''), false]
  when InterpolatedStringNode
    interpolated = false
    merged = false
    holes = []
    text = +''
    node.parts.each do |part|
      case part
      when StringNode
        chunk = part.unescaped.scrub('')
        merged ||= text.end_with?(SQL_HOLE) && chunk.match?(IDENT_HEAD) && !inside_string_literal?(text)
        text << chunk
      when InterpolatedStringNode
        # Соседние литералы, склеенные переносом строки (`"a " \` / `"b#{x}"`),
        # парсер отдаёт ВЛОЖЕННЫМИ узлами. Пока это не разворачивалось, каждая
        # такая часть схлопывалась в одну дыру целиком: из
        # `"INSERT … VALUES " "(#{a}, #{b})"` получалось `INSERT … VALUES $1`,
        # то есть половина запроса исчезала, и запрос уходил в слепые зоны.
        inner, inner_interp, inner_merged, inner_holes = literal(part)
        interpolated ||= inner_interp
        merged ||= inner_merged
        merged ||= text.match?(IDENT_TAIL) && inner.start_with?(SQL_HOLE) && !inside_string_literal?(text)
        merged ||= text.end_with?(SQL_HOLE) && inner.match?(IDENT_HEAD) && !inside_string_literal?(text)
        Array(inner_holes).each { |hole| holes << hole.merge(offset: text.length + hole[:offset]) }
        text << inner
      else
        interpolated = true
        merged ||= text.match?(IDENT_TAIL) && !inside_string_literal?(text)
        # Строка у дыры СВОЯ, а не начало литерала: у многострочного запроса
        # все дыры иначе показываются на первой строке, и найти их нельзя.
        holes << { expr: hole_expression(part), offset: text.length,
                   line: part.location.start_line }
        text << SQL_HOLE
      end
    end
    [text, interpolated, merged, holes]
  end
end
```

Части склейки надо помечать как «не самостоятельные строки» и не разбирать
отдельно — иначе обрубок уйдёт в слепые зоны рядом с целым запросом. Пометка
рекурсивная: вложенность бывает глубже одного уровня.

**Место подстановки** определяется по тексту слева. Порядок проверок значим: это
не стилистика, а правильность. `where id in (` — это `in_list`, а не `condition`;
`from ` — `table`, а `from users_` — `glue`.

```ruby
def hole_context(sql, offset)
  left = sql[0, offset].to_s
  return 'quoted' if inside_string_literal?(left)

  case left.gsub(/\s+/, ' ')
  # Любое место внутри незакрытой `in (…)`, а не только сразу за скобкой: во
  # `in ($1, $1)` вторая дыра такая же, как первая. Но если внутри скобки начался
  # select, мы уже в подзапросе, и место там другое.
  when /\bin\s*\((?![^)]*\bselect\b)[^)(]*\z/i                then 'in_list'
  when /\b(?:from|join|into|update|table)\s+(?:only\s+)?\z/i  then 'table'
  when /\border by [\w.,() ]*\z/i                             then 'order_by'
  when /\bgroup by [\w.,() ]*\z/i                             then 'group_by'
  when /\b(?:limit|offset)\s+\z/i                             then 'limit'
  when /\bvalues\s*(?:\([^)]*)?\z/i                           then 'values'
  when /(?:=|<|>|<=|>=|<>|!=|\blike|\bilike)\s*\z/i           then 'value'
  when /\bset\s+[\w.,'"= ]*\z/i                               then 'set'
  when /\b(?:where|and|or|on|having)\s+\z/i                   then 'condition'
  when /[A-Za-z0-9_$]\z/                                      then 'glue'
  when /\bselect\b/i                                          then 'select_list'
  else 'other'
  end
end
```

**Разбор запроса.** Подстановку заменяем, и только потом отдаём парсеру базы.
Дыры записываются ДО замены вопросительных знаков — иначе смещения съедут.

```ruby
def analyze_sql(sql, line, blind: false, holes: nil)
  @sql_here = true                      # пометка уйдёт в запись `def`
  record_holes(sql, holes, line, blind: blind) if holes.present?
  sql = sql.gsub(QUESTION_PLACEHOLDER, SQL_HOLE)
  # Дыра приросла к имени: разбирать нельзя. `shard_$1` база считает законным
  # идентификатором, и индекс наполнялся бы таблицами, которых не существует.
  return unparsed(sql, line, 'имя собрано интерполяцией') if blind
  return unparsed(sql, line, 'парсер недоступен') unless pg_query?

  parsed  = PgQuery.parse(sql)
  aliases = parsed.aliases
  tables  = parsed.tables.map { |t| unqualify(t) }
  tables.each { |t| add('pg_table', t, '', line) }

  column_refs(parsed).each do |qualifier, column|
    owner = if qualifier.nil?
              tables.size == 1 ? tables.first : ''   # одна таблица — привязка однозначна
            else
              unqualify(aliases[qualifier] || qualifier)
            end
    add('pg_column', column, owner, line)
  end

  json_keys(parsed).each do |base, key|
    next if key.include?(SQL_HOLE)      # ключ собран интерполяцией — это узор, не имя
    table = tables.size == 1 ? tables.first : '?'
    add('json_key', key, "#{table}.#{base.presence || '?'}", line)
  end
rescue StandardError => e
  unparsed(sql, line, e.message)
end

# Разбор не удался: помечаем слепым местом и вынимаем имя таблицы выражением —
# но ОТДЕЛЬНЫМ видом, потому что это догадка.
TABLE_GUESS = /\b(?:from|into|update|join)\s+(?:only\s+)?([a-z_][a-z0-9_]*)/i
GUESS_STOPLIST = %w[set now the only lateral values select where group order limit offset
                    generate_series unnest jsonb_array_elements regexp_split_to_table dual].freeze

def unparsed(sql, line, reason)
  add('sql_blind', sql.gsub(/\s+/, ' ').strip[0, 120], '', line, detail: { error: reason[0, 200] })
  sql.scan(TABLE_GUESS).flatten.map(&:downcase).uniq.each do |guess|
    add('pg_table_guess', guess, '', line) unless GUESS_STOPLIST.include?(guess)
  end
  nil
end
```

## IV.4. Обрывки запроса

Самый незаметный вид подстановки: `param_ar << "user_id in (#{sub})"`,
`return ["user_id in (#{sql})"] if sql`. Целого запроса рядом нет — ни `select`,
ни `from`, — разбирать нечем, и глаз такое проскакивает легче всего.

Дыра в обрывке записывается наравне с прочими (`fragment: true`), но отбор
жёсткий, иначе это чистый шум:

```ruby
# Обрывок: не предложение, а кусок, который потом склеят.
SQL_FRAGMENT = /
  \A[\s(]*(?:
      (?:and|or|not|where|having|order\s+by|group\s+by|limit|offset|set|values|
         union|from|join|inner\s+join|left\s+join|right\s+join|on)\b
    | [a-z_][a-z0-9_."]*\s+(?:not\s+)?(?:in\s*\(|like\b|ilike\b|is\b|between\b)
  )
/xi
# Ветки `имя = …` тут нарочно НЕТ. С ней в индекс шли строки лога и параметры
# URL: `"trace=#{id} n=#{n}"`, `"flm=#{a}&intp=#{b}"`, человеческое
# `"From #{кто} on balance"` — шестнадцать ложных находок из ста семи.

# Признак настоящего SQL, и признак сильный: `from` в него не входит нарочно —
# на нём в индекс шло человеческое «From … on balance».
SQL_PART_MARK = /\b(?:select|where|join)\b|\bin\s*\(/i

# Места, ради которых обрывок вообще разбирается. `value`, `quoted`, `limit` и
# `select_list` сюда не входят: там подставляется значение, а обрывков со
# значениями в коде десятки тысяч.
FRAGMENT_CONTEXTS = %w[in_list condition table glue order_by group_by set values].freeze
# …а этим двум признак SQL не нужен: «order by» посреди обычного текста не
# встречается.
FRAGMENT_SELF_EVIDENT = %w[order_by group_by].freeze
```

Запись дыры с воротами:

```ruby
def record_holes(sql, holes, line, blind:, carrier: nil, fragment: false)
  whole  = sql.gsub(/\s+/, ' ').strip[0, 200]
  marked = fragment && sql.match?(SQL_PART_MARK)
  holes.each do |hole|
    expr = hole[:expr].to_s
    next if expr.empty?

    context = hole_context(sql, hole[:offset])
    if fragment
      next unless FRAGMENT_CONTEXTS.include?(context)
      next if !marked && !FRAGMENT_SELF_EVIDENT.include?(context)
    end

    root = hole_root(expr)
    hole_line = hole[:line] || line
    # Одна и та же дыра может прийти дважды (обрывок разбирается и сам по себе,
    # и как значение присваивания) — один ряд, а не два вхождения.
    next unless @recorded_holes.add?([hole_line, hole[:offset], root])

    @wanted_roots << root unless root.empty?
    add('sql_hole', root.presence || expr[0, 60], context, hole_line,
        detail: { expr: expr[0, 200], scope: scope_name, blind: blind, sql: whole,
                  carrier: carrier, fragment: fragment,
                  call: expr_call(expr), const: (root if root.match?(/\A[A-Z]/)) })
  end
end

# Корень выражения: то имя, по которому его вообще можно искать.
# `ids.map(&:to_i).join(',')` -> `ids`, `params[:sort]` -> `params`.
HOLE_ROOT = /\A[@$]{0,2}[A-Za-z_][A-Za-z0-9_]*[!?]?/
def hole_root(expr) = HOLE_ROOT.match(expr.to_s)&.to_s.to_s

# `Repo.new.take_all` — носитель не класс, а его метод. Корень тут константа, и
# по ней метод не найти: искать надо `take_all`. У `ids.map(&:to_i)` корень
# строчный — там последний вызов это `map`, и искать по нему нечего.
def expr_call(expr)
  head = expr.to_s[/\A[A-Za-z_][\w:]*(?:\s*\.\s*[a-z_]\w*[!?]?)+/]
  return nil if head.nil? || !head.match?(/\A[A-Z]/)

  parts = head.split('.').map(&:strip)
  parts.pop if parts.size > 2 && parts.last == 'new'
  parts.last == 'new' ? nil : parts.last
end
```

**Переменная, накапливающая запрос.** `sql = 'select …'` затем
`sql << " where id in (#{ids})"` — самый частый способ собрать запрос по кускам.
Фрагмент целиком не разобрать, но дыра в нём та же:

```ruby
def note_assign(name, value, line, operator)
  return if value.nil?

  text, interpolated, merged, holes = literal(value)
  source     = value.slice.to_s[0, 400]
  whole_sql  = text.to_s.match?(SQL_START)
  part_sql   = !whole_sql && text.to_s.match?(SQL_FRAGMENT) && text.to_s.match?(SQL_PART_MARK)
  # Порядок важен: «несёт запрос» — это про то, что было ДО этой строки.
  carrier    = @sql_vars.include?(name)
  @sql_vars << name if whole_sql

  record_holes(text, holes, line, blind: merged, carrier: name) if carrier && !whole_sql && holes.present?

  @assigns << {
    name: name, line: line, scope: scope_name, operator: operator, carrier: carrier,
    value: (text || source).gsub(/\s+/, ' ').strip[0, 200],
    sql: whole_sql, part: part_sql, literal: !text.nil?, interpolated: interpolated,
    # Значение не строка (`cond = build_cond`) — запоминаем, ЧЕМ оно берётся: по
    # этому имени дыра дотягивается до метода, который её наполняет.
    from: text.nil? ? (expr_call(source) || hole_root(source)) : nil,
    const: (text.nil? && hole_root(source).match?(/\A[A-Z]/) ? hole_root(source) : nil),
    holes: Array(holes).map { |h| h[:expr] }.first(4)
  }
end
```

**Отбор присваиваний.** Записывать все — значит раздуть индекс тем же, чем раздут
поиск по тексту. Запись попадает в индекс, если выполнено хоть одно условие:
значение похоже на запрос; имя подставляется в дыру или в шаблон в этом же файле;
имя видно за границей файла (`@`, `@@`, `$`, константа). Прочие локальные
переменные — шум: их сотни тысяч, а ответить ими не на что.

## IV.5. Объявления: видимость и «возвращает запрос»

**Видимость — ключ к отчёту о неиспользуемом.** У приватного метода «вызовов по
имени нет» почти доказательство: снаружи класса его и не позвать. У публичного то
же самое значит лишь «в этом дереве не зовут». Учесть надо все четыре способа
задать видимость, иначе половина методов будет помечена неверно:

```ruby
VISIBILITY = %w[private protected public].freeze

# `private` меняет видимость всего, что объявлено ниже в ЭТОМ теле; три другие
# формы называют методы поимённо.
def handle_visibility(node)
  name = node.name.to_s
  return unless VISIBILITY.include?(name) || name == 'private_class_method'
  return unless node.receiver.nil?

  level     = name == 'private_class_method' ? 'private' : name
  arguments = node.arguments&.arguments || []
  if arguments.empty?
    @visibility[-1] = level if VISIBILITY.include?(name)   # переключатель в теле класса
    return
  end

  @pending_visibility = level if arguments.any? { |a| a.is_a?(DefNode) }   # `private def x`
  arguments.each do |argument|                                            # `private :x, :y`
    next unless argument.is_a?(SymbolNode) || argument.is_a?(StringNode)

    @visibility_named[[@scope_stack.join('::'), argument.unescaped.to_s]] = level
  end
end
```

Кадр видимости заводится на каждое тело класса и модуля, и **отдельный** — на
блок методов класса (`class << self`): там свой независимый переключатель.
`private :старый_метод` стоит НИЖЕ объявления, поэтому поимённые пометки
применяются после обхода файла, а не во время.

**«Возвращает запрос» и «в теле есть запрос» — разные признаки.** Первое верно
для единиц, второе — почти для любой модели (у нас 75 против 2123). Если
перепутать, в отчёт попадёт пол-репозитория, и его перестанут читать.

```ruby
# => 'whole' | 'part' | nil
def sql_return_kind(node)
  body = node.body
  last = body.is_a?(StatementsNode) ? body.body.last : nil
  # Разматываем хвост вызовов: господствует форма `<<~SQL.squish`, и без
  # разматывания она читается как вызов, а не как строка.
  last = last.receiver while last.is_a?(CallNode) && last.receiver
  case last
  when LocalVariableReadNode, InstanceVariableReadNode
    @sql_vars.include?(last.name.to_s) ? 'whole' : nil
  else
    text, = literal(last)
    return 'whole' if text.to_s.match?(SQL_START)
    # `(user_id in (select …))` — не предложение, а готовое условие. Для дыры
    # разницы нет: подставится кусок запроса.
    return 'part' if text.to_s.match?(SQL_FRAGMENT) && text.to_s.match?(SQL_PART_MARK)

    nil
  end
end
```

Ветвление в конце (`if a then sql1 else sql2 end`) сюда не попадает — известный
недобор. Лучше недобрать, чем позвать в отчёт каждый метод модели.

**Параметры метода записывайте.** Если корень дыры — параметр, значит текст
приходит от вызывающего, и отчёт должен сказать это, а не молчать.

## IV.6. Шаблоны и вызовы отрисовки

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

- имя стоит в коде целиком → **факт** (`render_ref`);
- имя собрано интерполяцией, но литеральные куски есть (`"layouts/#{kind}"`) → из
  них собирается узор `layouts/*`, и по узору выдаются рёбра-**кандидаты**;
- имя целиком в переменной → честное **слепое место**: сказать нечего, кроме
  того, что оно есть.

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

Тонкости, без которых разбор теряет две трети вызовов:

- **старая форма с хвостовой стрелкой господствует** (`render :partial => 'x'`),
  и ключей в одном вызове может быть несколько — берите все;
- **проверяйте, что слово стоит в позиции вызова**, а не в тексте страницы и не в
  комментарии: иначе в слепые зоны попадут комментарии и строки-подсказки из
  тестов. Признак — непарные кавычки слева и список допустимых предшественников;
- **узор экранируйте тильдой**, а не обратным слэшем: в именах шаблонов сплошные
  подчёркивания, а для `LIKE` это подстановочный знак. По той же причине суффикс
  имени сравнивайте через `right()`, а не `LIKE`.

**Логическое имя шаблона** — путь без корня шаблонов, ведущего подчёркивания и
расширений. Приставку варианта интерфейса в нём ОСТАВЛЯЙТЕ: сводит варианты не
разборщик, а сравнение по суффиксу в представлении рёбер. Тогда одно имя в коде
ведёт разом во все варианты, а имя файла не теряется.

**Поиск вызовов в шаблонах** (нужен для вопроса «кто зовёт этот хелпер»): дерева
парсер не построит, но из тегов собирается настоящий код — ровно так, как это
делает сам шаблонизатор.

```ruby
ERB_TAG = /<%(?<kind>[=\-#]?)(?<code>.*?)-?%>/m

# Текст между тегами выбрасывается, переводы строк СОХРАНЯЮТСЯ (иначе номера
# строк разъедутся с файлом), `<% if %>` и `<% end %>` из разных тегов сходятся
# в одно выражение.
def erb_ruby(source)
  out = +''
  position = 0
  each_erb_tag(source) do |code, _line, match|
    out << ("\n" * source[position...match.begin(0)].to_s.count("\n"))
    out << code << ' ; '
    position = match.end(0)
  end
  out << ("\n" * source[position..].to_s.count("\n"))
  out
end

def each_erb_tag(source)
  source.to_enum(:scan, ERB_TAG).each do
    match = Regexp.last_match
    code  = match[:kind] == '#' ? match[:code].gsub(/[^\n]/, ' ') : match[:code]  # комментарий — пробелами
    yield code, source[0...match.begin(0)].count("\n") + 1, match
  end
end
```

Если целиком не разобралось (тег внутри атрибута, обрывок на два шаблона) —
разберите каждый тег порознь: часть вызовов всё равно видна. Без разбора
шаблонов ответ «кто зовёт» врёт в самую частую сторону: у одного хелпера
выходило 4 вызова вместо 19 при трёх тысячах шаблонов рядом.

## IV.7. Факты у запущенного приложения

Самый дешёвый слой и самый недооценённый. Поднимите приложение, дайте догрузить
все классы и спросите у него: модели с таблицами и путями к файлам, связи,
роуты, контроллеры с их действиями, расписание фоновых работ, карту
«константа → файл» от автозагрузчика.

Что важно:

- **не угадывайте то, что можно спросить.** Попытка вычислить «класс `UserProfile`
  лежит в `user_profile.rb`» создаёт второй источник правды, который разойдётся с
  настоящим на первом нестандартном имени;
- **карта констант от автозагрузчика** превращает ссылку на класс в ребро графа
  «файл → файл». Это главный поставщик рёбер;
- **таблицу модели берите у модели**, а не у имени класса. Составной первичный
  ключ приходит массивом — приводите к строке;
- **к какой базе приписана модель** определяйте по ближайшему абстрактному
  предку, а не по текущему подключению: иначе все модели окажутся в основной базе;
- **каждый вызов оборачивайте в защиту**: одна модель, падающая при обращении к
  метаданным, не должна рушить сбор. Неудачу записывайте, а не глотайте.

## IV.8. Где код выполняется

Нужен, если среды больше одной или код расщеплён по ролям (веб, воркеры, фоновые
роботы). Снимите **с живых контейнеров** состав служб: что запущено и какую
задачу выполняет. Инвентарь, который ведут руками, отстаёт всегда.

Соединение этого слоя с первым даёт самый ценный отчёт в наборе: **таблицы в
этой среде нет, а код, который её трогает, именно здесь и выполняется.**

## IV.9. Содержимое бесхемных полей

Для каждого поля-json: выборка строк с ограничением, перечисление ключей
верхнего уровня, подсчёт. По одному оператору на колонку и срок на оператор —
иначе одна тяжёлая колонка убьёт весь сбор. Только чтение.

Собирайте с нескольких сред и **помечайте источник**: ключ, встреченный только на
стенде, — след опытов, а не боевой факт.

## IV.10. Логи боя

**Два разных счётчика за один проход**, и путать их нельзя:

```awk
/ Processing by / { i = index($0, "Processing by "); rest = substr($0, i + 14);
                    j = index(rest, " as "); if (j > 0) rest = substr(rest, 1, j - 1);
                    act = rest; a[act]++; next }
/ Rendered /      { i = index($0, "Rendered "); rest = substr($0, i + 9);
                    if (substr(rest, 1, 7) == "layout ") rest = substr(rest, 8);
                    j = index(rest, " ("); if (j > 0) rest = substr(rest, 1, j - 1);
                    sub(/[ \t]+$/, "", rest); if (rest == "") next; c[act "|" rest]++ }
END { for (k in a) print "A|" k "|" a[k]; for (k in c) print "V|" k "|" c[k] }
```

- `Rendered` — какой шаблон нарисован;
- `Processing by` — какое ДЕЙСТВИЕ запрашивали.

Разница решающая: действие, отвечающее переходом, json-ом или пустым телом,
**не рисует шаблонов** и по первому счётчику выглядит мёртвым. У нас по этой
причине в мёртвые попал корень сайта.

Четыре правила:

1. **Разбирайте на машине, где логи лежат.** Их гигабайты в сутки; обратно едет
   сводка, а не логи. Передавайте скрипт на вход `ssh host sh -s`.
2. **Проверьте, законна ли привязка** шаблона к действию. У нас она точна потому,
   что воркер однопоточный: строки идут строго подряд. Если у вас потоки —
   привязка по соседству неверна, нужен идентификатор запроса в строке лога.
3. **Копите по дням** и храните с разбивкой по среде, узлу и дню. Один день
   доказывает использование и ничего не говорит о смерти.
4. **Пустой ответ не затирает собранное.** Лог за день может лежать на узле и не
   содержать ни одной нужной строки; схема «удалить прежнее и вставить новое»
   пустым списком уничтожит снимок, собранный, когда лог был полным. Так у нас
   пропал собранный ранее день. Правило: нечего вставлять — ничего не удалять, и
   различайте «логов нет» и «в логах ничего не нашлось».

---

# Часть V. Представления

Отчёты пишутся по этим представлениям, а не по таблицам напрямую. Приведены те,
где логика неочевидна.

**Последний удавшийся снимок каждого источника** — основа всего остального:

```sql
create view v_current as
select distinct on (s.source_id)
       s.id as snapshot_id, s.source_id, src.name as source, src.kind as source_kind,
       s.taken_at, s.git_sha, s.server_version, s.database_name
from snapshots s
join sources src on src.id = s.source_id
where s.ok
order by s.source_id, s.taken_at desc, s.id desc;

-- Реляционная база и колоночная живут в одних таблицах снимков, но в РАЗНЫХ
-- пространствах имён: одно и то же имя там и там — разные вещи.
create view v_tables as
select c.source, t.name, t.kind, t.est_rows
from db_tables t join v_current c on c.snapshot_id = t.snapshot_id
where c.source_kind = 'pg';

create view v_columns as
select c.source, col.table_name, col.column_name, col.ordinal,
       col.data_type, col.not_null, col.default_expr
from db_columns col join v_current c on c.snapshot_id = col.snapshot_id
where c.source_kind = 'pg';
```

**Где таблица есть и где её нет.** `missing_in` считается только по источникам
того же вида:

```sql
create view v_table_presence as
select g.name, g.present_in,
       array(select s.source from v_current s
             where s.source_kind = 'pg' and not (s.source = any(g.present_in))
             order by 1) as missing_in
from (select name, array_agg(source order by source) as present_in
      from v_tables group by name) g;

create view v_column_presence as
select g.table_name, g.column_name, g.present_in, g.types, g.types_by_source,
       array(select s.source from v_current s
             where s.source_kind = 'pg' and not (s.source = any(g.present_in))
             order by 1) as missing_in
from (select table_name, column_name,
             array_agg(source order by source) as present_in,
             array_agg(distinct data_type) as types,
             array_agg(source || ':' || data_type order by source) as types_by_source
      from v_columns group by table_name, column_name) g;

-- Колонка есть не везде — но только у таблиц, которые есть ВЕЗДЕ. Иначе отчёт
-- тонет в колонках резервных таблиц одной среды.
create view v_column_drift as
select cp.* from v_column_presence cp
join v_table_presence tp on tp.name = cp.table_name
where cardinality(cp.missing_in) > 0 and cardinality(tp.missing_in) = 0;
```

**Рёбра графа.** Ссылка на константу — только один их сорт; сама по себе она
оставляет вне графа все шаблоны и все связи моделей.

```sql
create view v_const_edges as
select r.path as from_path, r.owner as scope, c.path as to_path, 'const' as kind
from code_refs r
join app_constants c on c.constant = r.token
where r.kind = 'const_ref' and c.path <> r.path;

-- Имя шаблона в коде ведёт во ВСЕ варианты интерфейса сразу: логическое имя у
-- них общее, файлы разные. Суффикс сравниваем через right(), а не LIKE: в именах
-- шаблонов сплошные подчёркивания, а для LIKE это подстановочный знак.
create view v_render_edges as
select r.path as from_path, '' as scope, v.path as to_path, 'render' as kind
from code_refs r
join code_refs v
  on v.kind = 'view_name'
 and (v.token = r.token or right(v.token, length(r.token) + 1) = '/' || r.token)
where r.kind = 'render_ref' and v.path <> r.path;

-- Ребро-КАНДИДАТ: имя дособирается на ходу, но узор из литеральных кусков есть.
-- Экранирование тильдой — по той же причине.
create view v_render_candidate_edges as
select r.path as from_path, '' as scope, v.path as to_path, 'render?' as kind
from code_refs r
join code_refs v
  on v.kind = 'view_name'
 and (v.token like (r.detail->>'glob') escape '~'
      or v.token like ('%/' || (r.detail->>'glob')) escape '~')
where r.kind = 'render_dynamic' and r.owner = 'skeleton'
  and r.detail->>'glob' is not null and v.path <> r.path;

-- Контроллер рисует шаблон ПО ИМЕНИ ДЕЙСТВИЯ, без всякого вызова отрисовки.
-- Без этого ребра шаблоны висят отдельным островом вообще без точек входа.
create view v_action_view_edges as
select c.path as from_path, '' as scope, v.path as to_path, 'action' as kind
from app_routes r
join app_controllers c on c.class_name = r.controller_class and c.path is not null
join code_refs v
  on v.kind = 'view_name'
 and (v.token = r.controller || '/' || r.action
      or right(v.token, length(r.controller) + length(r.action) + 2)
         = '/' || r.controller || '/' || r.action)
where r.controller <> '' and r.action <> '';

-- Связь модели — тоже ребро: объявленная связь ведёт в класс на другом конце.
create view v_assoc_edges as
select mf.path as from_path, '' as scope, tf.path as to_path, 'assoc' as kind
from app_associations a
join app_constants mf on mf.constant = a.class_name
join app_constants tf on tf.constant = a.target
where a.target is not null and tf.path <> mf.path;

create view v_code_edges as
select from_path, scope, to_path, kind from v_action_view_edges
union all select from_path, scope, to_path, kind from v_const_edges
union all select from_path, scope, to_path, kind from v_render_edges
union all select from_path, scope, to_path, kind from v_render_candidate_edges
union all select from_path, scope, to_path, kind from v_assoc_edges;
```

**Точки входа и достижимость.** Точка входа — это место, откуда код начинает
выполняться, И среда, в которой это происходит: роутованные контроллеры во всех
средах, фоновые работы в тех средах, где включены, службы каналов, задачи
сборщика через состав служб.

```sql
create view v_entry_points as
-- Роутованный контроллер — точка входа в КАЖДОЙ среде, где поднят веб.
select c.path, p.place, 'controller' as kind, c.class_name as entry
from app_controllers c
join app_routes r on r.controller_class = c.class_name
cross join (select distinct place from run_units) p
where c.path is not null
group by c.path, p.place, c.class_name
union all
-- Фоновая работа — только там, где она включена ('*' — везде).
select j.path, rp.place, 'job', j.class_name
from app_jobs j
cross join lateral unnest(j.places) as d(v)
join run_places rp on rp.env_id = d.v or d.v = '*'
where j.path is not null
union all
-- Служба каналов: поднята в каждой среде, а точкой входа не была — весь код,
-- доступный только из канала, лежал вне графа.
select c.path, u.place, 'channel', c.constant
from app_constants c
join run_units u on u.unit = 'channels-rpc'
where c.path like 'app/channels/%';

-- Фоновые роботы: служба -> задача сборщика -> файл, где эта задача объявлена.
create view v_entry_rake as
select cr.path, u.place, u.task as scope, u.unit
from run_units u
join code_refs cr on cr.kind = 'task_entry' and cr.token = u.task
where u.task is not null;

create view v_reach as
with recursive seed as (
  select path, place from v_entry_points
  union select path, place from v_entry_rake
  union select e.to_path, s.place
         from v_entry_rake s
         join v_code_edges e on e.from_path = s.path and e.scope = s.scope
),
walk (path, place, depth) as (
  select path, place, 0 from seed
  union
  select e.to_path, w.place, w.depth + 1
  from walk w
  join v_code_edges e on e.from_path = w.path and e.scope = ''
  where w.depth < 9                  -- потолок: граф сходится на 5, запас на рост
)
select path, place, min(depth) as depth from walk group by path, place;
```

**Достижимость ошибается в обе стороны, и это надо записать в самом
представлении.** Вширь: ссылка на константу ещё не вызов, а условная ветка
неотличима от безусловной. Вглубь: вызов на объекте графу невидим, как и
динамический вызов по имени. Попадание сюда значит «похоже, достижимо»;
НЕ-попадание не значит ничего.

**Ради чего всё:**

```sql
create view v_break_here as
select r.token as table_name, rc.place, r.path, r.kind, rc.depth,
       array_to_string(r.lines, ',') as lines
from code_refs r
join v_table_presence tp on tp.name = r.token
join v_reach rc on rc.path = r.path
where r.kind in ('pg_table', 'pg_table_guess')
  and rc.place = any(tp.missing_in);
```

**Откуда в подстановку приходит текст.** Три дороги, и все три — кандидаты.
Главное здесь — **сила связи**: по одному совпавшему имени верить нельзя.

```sql
-- Разворот detail в колонки. `scopes` — все области, где встретилась эта же
-- подстановка: ряд один, а методы разные, и scope от первого про остальные врёт.
create view v_sql_holes as
select path, token as root, owner as context,
       detail->>'expr' as expr, detail->>'scope' as scope,
       array_to_string(array[detail->>'scope'] ||
                       coalesce(array(select jsonb_array_elements_text(detail->'scopes')), '{}'),
                       ', ') as scopes,
       coalesce((detail->>'blind')::boolean, false) as blind,
       coalesce((detail->>'fragment')::boolean, false) as fragment,
       detail->>'carrier' as carrier, detail->>'call' as call,
       detail->>'const' as const, detail->>'sql' as sql,
       count, lines
from code_refs where kind = 'sql_hole';

create view v_assigns as
select path, token as name, owner as scope, detail->>'value' as value,
       coalesce((detail->>'sql')::boolean, false) as is_sql,
       coalesce((detail->>'part')::boolean, false) as is_part,
       coalesce((detail->>'carrier')::boolean, false) as carrier,
       detail->>'op' as op, detail->>'from' as from_call, detail->>'const' as from_const,
       detail->'more' as more, count, lines
from code_refs where kind = 'assign';

-- Три РАЗНЫХ признака, и путать их нельзя:
--   has_sql      — в теле метода есть запрос (верно почти для всех моделей);
--   returns_sql  — метод ВОЗВРАЩАЕТ предложение целиком;
--   returns_part — возвращает обрывок. Для подстановки это то же самое.
create view v_defs as
select path, token as method, nullif(owner, '') as class_name,
       coalesce((detail->>'sql')::boolean, false) as has_sql,
       coalesce((detail->>'returns')::boolean, false) as returns_sql,
       coalesce((detail->>'returns_part')::boolean, false) as returns_part,
       coalesce((detail->>'singleton')::boolean, false) as singleton,
       coalesce(detail->>'visibility', 'public') as visibility,
       coalesce(array(select jsonb_array_elements_text(detail->'params')), '{}') as params,
       count, lines
from code_refs where kind = 'def';

create view v_sql_hole_source as
with h as (
  select *, nullif(regexp_replace(scope, '[#.][^#.]*$', ''), '') as class_name
  from v_sql_holes
),
d as (
  select *, (returns_sql or returns_part) as gives_sql from v_defs
)
-- тому же имени что-то присваивали
select h.path, h.root, h.context, h.expr, h.scope, h.lines as hole_lines,
       'assign' as source_kind, a.path as source_path, a.scope as source_scope,
       a.value as source_value, (a.is_sql or a.is_part) as source_is_sql,
       case when a.path = h.path then 'файл'
            when regexp_replace(a.scope, '[#.][^#.]*$', '') = h.class_name then 'класс'
            else 'имя' end as match
from h
join v_assigns a
  on a.name = h.root
 -- Локальная переменная ищется ТОЛЬКО в своём файле: локальные `sql` из разных
 -- файлов не связаны между собой никак. @/$/КОНСТАНТА — по всему дереву.
 and (a.path = h.path or h.root ~ '^[@$]' or h.root ~ '^[A-Z]')
union all
-- так называется метод; `h.call` — для `Repo.new.take_all`, где корень константа
select h.path, h.root, h.context, h.expr, h.scope, h.lines,
       'def', d.path, coalesce(d.class_name, ''), '', d.gives_sql,
       case when d.path = h.path then 'файл'
            when d.class_name = h.class_name then 'класс'
            when h.const is not null and d.path = c.path then 'класс'
            else 'имя' end
from h
left join app_constants c on c.constant = h.const
join d on d.method = coalesce(h.call, h.root)
union all
-- присвоили вызовом (`cond = build_cond`), и вот этот метод
select h.path, h.root, h.context, h.expr, h.scope, h.lines,
       'assign->def', d.path, coalesce(d.class_name, ''), a.value, d.gives_sql,
       case when d.path = h.path then 'файл'
            when d.class_name = h.class_name then 'класс'
            when a.from_const is not null and d.path = c.path then 'класс'
            else 'имя' end
from h
join v_assigns a on a.name = h.root and a.path = h.path and a.from_call is not null
left join app_constants c on c.constant = a.from_const
join d on d.method = a.from_call;
```

**Если источника нет, скажите почему.** Подстановка, чей корень — параметр
метода, в котором она стоит: текст приходит от вызывающего, и молчание тут
читается как «источника нет», хотя он есть и лежит на шаг выше.

```sql
create view v_sql_hole_from_param as
select h.path, h.root, h.context, h.expr, h.scope, h.lines, d.method, d.params
from v_sql_holes h
join v_defs d
  on d.path = h.path
 and h.scope = coalesce(d.class_name, '') || case when d.singleton then '.' else '#' end || d.method
 and h.root = any(d.params);
```

---

# Часть VI. Отчёты

Правила, общие для всех отчётов. Нарушение любого убивает доверие к инструменту
быстрее, чем любая ошибка в разборе.

**Факт, кандидат и догадка — в разных разделах.** Не в одном списке с пометкой, а
именно в разных разделах с разными заголовками.

**Называйте силу связи у каждой строки.** `файл` / `класс` / `имя`. Последнее —
шум; его либо не печатать, либо отдельной строкой «ещё N связей найдено только по
совпадению имени — это не находки».

**Частое имя не раскрывайте.** Если имя объявлено в дереве девять раз и «вызовов»
у него тысяча шестьсот — это чужие вызовы. Печатайте «имя частое (N объявлений),
сводка не значит ничего», а не числа.

**Пустой список — это ответ, и он печатается словами.** «По имени не зовут нигде
— ни из кода, ни из шаблонов, ни через динамический вызов с именем-литералом;
остаётся перехват обращений и имя, собранное на лету». Молчание не ответ.

**«Мёртв» всегда вместе с «что его держит».** Рядом с «вызовов нет» печатайте
упоминания вне вызовов: тесты, описи, документы, конфигурации, комментарии. У нас
первая же находка была такой: метод не зовут нигде, но на его строку заведено
исключение в тесте-стороже. Удалять надо парой.

**Делите результат по силе вывода.** «Ничем не помянуто — снимается в одиночку»
и «держат упоминания — снимать парой». Первое — безопасный список, второе — на
разбор.

**Меряйте не только в штуках.** «Двести мёртвых методов» — число без масштаба.
Двести однострочных обёрток и двести стострочных разборов — разный разговор.
Границы метода берите у парсера, а не счётом до следующего объявления: между
методами лежат комментарии, и принадлежат они тому, что ниже.

**Защёлка вместо нуля.** Для архитектурных правил: столько нарушений терпим
сегодня, отчёт молчит, пока число не выросло. Этим же приёмом оформляется
счётчик незаконченного переноса — число должно только падать.

**Числа старятся.** Печатайте рядом команду, которая пересчитывает, и ставьте
оговорку «числа — на день правки».

---

# Часть VII. Правила, которые дороже кода

Если из спецификации останется один раздел, пусть будет этот. Каждый пункт
однажды стоил инцидента, потерянных данных или ложного отчёта.

1. **Инструмент, который не называет своих слепых мест, врёт молча.** Отсутствие
   записи читается как «связей нет». Молчание — не ответ; «не знаю» — ответ.
2. **Отсутствие доказательства не есть доказательство отсутствия.** Логи за сутки
   доказывают, что экран используется, и ничего не говорят о том, что он мёртв.
   Пишите это прямо в отчёте, рядом с числами.
3. **Проверяйте, что правка подействовала.** Если после ужесточения условия число
   в отчёте не изменилось — подозревайте, что условие не применилось.
4. **Функция сжатия пробелов съедает SQL-комментарии.** Схлопнув запрос в одну
   строку, она уводит в `--` всё до конца запроса: условие молча выключается,
   запрос остаётся верным и отрабатывает. Комментарий к такому запросу ставьте
   комментарием языка НАД текстом, а не внутри SQL.
5. **Пустой ответ не затирает собранное.** Потеря обновления дешевле потери
   данных.
6. **Не требуйте инструментальных зависимостей из кода, который грузится в бою.**
   Парсер SQL нужен только разработчику; если его `require` стоит в файле,
   который приложение загружает при старте, боевой образ без этой зависимости не
   поднимется. Грузите по требованию и переживайте отсутствие: без парсера индекс
   просто беднее.
7. **Сортировка зависит от локали.** Первое сравнение двух списков имён дало
   мусор — имена оказались в обеих сторонах разом. Любое сличение — с
   фиксированной локалью.
8. **Приставка шарда — не таблица.** Прежде чем верить строке «таблицы нет в этой
   среде», проверьте, не приставка ли это: код может ходить в `t_0…t_N`, которые
   есть везде, а в индекс попала догадка `t`.
9. **Одно имя — одна строка на файл.** Строка на каждое вхождение даёт сотни
   тысяч рядов и ровно столько же пользы, сколько одна.
10. **Невалидный UTF-8 приходит из парсера.** Чистите каждый текст, иначе запись
    в базу падает на случайном файле.
11. **Прерванный разбор не оставляет половины.** Удаление и вставка записей файла
    — в одной транзакции.
12. **Тесты на разборщик — не роскошь.** Правила тонкие и ломаются молча: отчёт
    продолжает печататься, просто с другими числами. Полсотни примеров на
    фикстурах, каждый из которых закрывает уже случившуюся ошибку.
13. **Общий рабочий каталог.** Если в дереве работают несколько процессов,
    коммитьте по путям и никогда не прячьте изменения в стек.

---

# Часть VIII. Тесты: что утверждать

Фикстурные примеры на разборщик. Список — минимальный набор, ниже которого
инструмент начинает молча врать. У нас их 34, выполняются за 0,2 с, и первый же
прогон нашёл двойную запись, из-за которой счёт вызовов был завышен вдвое.

**Разобранный SQL:** таблица и колонка вынуты; колонка привязана к таблице;
ключ внутри json-поля вынут вместе с полем.

**Подстановка:**
- имя таблицы, собранное на лету, НЕ попадает в факты; попадает в слепые зоны;
  приставка попадает в догадки;
- у записи есть выражение, корень и место в запросе;
- `in (…)` опознаётся и для второго элемента списка, не только для первого;
- различаются значение, кавычки и целое условие;
- строки, склеенные переносом, разбираются как ОДИН запрос: таблица найдена,
  слепых зон нет, места подстановок правильные;
- у каждой подстановки своя строка, а не начало литерала.

**Обрывки:**
- дыра в куске условия записывается с пометкой;
- строка лога и параметры адреса обрывком НЕ считаются;
- без сильного признака SQL обрывок не берётся, с признаком — берётся.

**Объявления:**
- все четыре способа задать видимость дают верный результат (девять методов —
  девять ожиданий);
- методы класса, объявленные блоком, помечены как методы класса;
- «возвращает предложение», «возвращает обрывок» и «в теле есть запрос» —
  три разных признака на трёх разных методах;
- параметры записаны.

**Обёртки колоночной базы:** таблицей считается первый аргумент ТОЛЬКО у обёрток
из явного списка; динамическое имя даёт выражение и узор, а не таблицу.

**Отрисовка:** старая форма с хвостовой стрелкой видна; обе ветки тернарного
выражения видны; интерполированное имя даёт узор; имя целиком в переменной даёт
слепое место; комментарии и текст страницы отрисовкой не считаются; логическое
имя шаблона собрано верно.

**Кандидаты:** имя из базы в тексте даёт один ряд с верным счётчиком; колонка из
аргументов ORM привязана к таблице модели; переменная, которой присвоили запрос,
записана вместе со значением и с дописанными кусками.

**Поиск вызовов** (на настоящих файлах во временном каталоге):
- объявление не считается вызовом;
- названы файл, строка, окружающий метод и аргументы;
- шаблон: номера строк совпадают с файлом; ветвление по разным тегам разобрано;
  комментарий шаблона не читается;
- динамический вызов с именем-литералом считается вызовом и отдаёт остальные
  аргументы; имя, собранное на лету, считается отдельно и вызовом не называется;
- символ — такая же зацепка, как вызов, и НЕ даёт второй записи рядом с вызовом;
- упоминания вне вызовов разобраны по разряду, и объявление в них не попало.

Отдельная ловушка в самих тестах: если фикстура написана литералом с
интерполяцией, подстановка выполнится В ТЕСТЕ, разборщик получит строку без
дыры, и пример пройдёт по неверной причине. Пишите фикстуры литералами без
интерполяции.

---

# Часть IX. Порядок работ

Польза приходит быстрее всего в таком порядке:

1. **Схемы всех сред по отдельности** + отчёт о расхождениях. Полдня, и уже
   находит то, о чём никто не знал.
2. **Разбор SQL настоящим парсером** + «где трогают эту таблицу/колонку» с
   разделением факт/кандидат. Главный слой.
3. **Факты у запущенного приложения.** Почти бесплатно, связывает всё остальное.
4. **Инкрементальность** по контрольным суммам, чтобы запрос догонял индекс сам.
   После этого инструментом начинают пользоваться.
5. **Тесты на разборщик** — до того, как правил станет много.
6. Дальше по потребности: место исполнения, подстановки, логи боя,
   неиспользуемое, задетое правкой, архитектурные защёлки.

Холодный старт:

```sh
bin/rails xref:init     # создать базу индекса и накатить её схему
bin/rails xref:schema   # снять схемы всех источников
bin/rails xref:app      # факты у запущенного приложения
bin/rails xref:places   # состав служб с живых контейнеров
bin/rails xref:code     # разобрать дерево (первый проход — минута)
bin/rails xref:json     # ключи внутри бесхемных полей
bin/rails xref:views DATE=…   # логи за день; копить по дням
```

Зависимости: разбор кода идёт ПОСЛЕ снятия схем (без имён из базы не будет
кандидатов) и ПОСЛЕ фактов приложения (без моделей обращения через ORM не
привязать к таблицам).

**Раскладка файлов.** Каждый файл отвечает за один слой и делается отдельно от
остальных; сборщики ничего не печатают, отчёты ничего не разбирают.

| Файл | За что отвечает |
| --- | --- |
| `config/xref.yml` | единственный источник правды: хранилище, источники, логи, правила |
| `lib/xref.rb` | чтение конфига |
| `lib/xref/store.rb` | схема индекса и вся запись в него |
| `lib/xref/schema_source.rb` | снятие схемы одного источника, транспорты |
| `lib/xref/code_scanner.rb` | разбор одного файла |
| `lib/xref/call_sites.rb` | места вызова и упоминания по требованию |
| `lib/xref/rails_facts.rb` | факты у запущенного приложения |
| `lib/xref/run_places.rb` | состав служб с живых контейнеров |
| `lib/xref/json_keys.rb` | ключи внутри бесхемных полей |
| `lib/xref/view_runtime.rb` | разбор боевых логов на узле |
| `lib/tasks/xref.rake` | задачи и все отчёты |
| `bin/xref` | обёртка |
| `spec/lib/xref/` | тесты на разборщик и на поиск вызовов |

## Чего не делать

**Не пишите второй разборщик схемы.** Дамп грузите в базу и снимайте запросом.

**Не стройте граф вызовов с разрешением получателя** на большом динамическом
коде. Он даст уверенность, которой не заслуживает. Разбор по требованию по
названным именам — честнее и в сто раз дешевле. Поиск по целым словам и
фиксированной строке; если имён сотни, поиск встроенными средствами системы
контроля версий не укладывается в разумное время — одно объединённое выражение и
один проход по файлам быстрее на порядки.

**Не включайте в бою тяжёлую диагностику** ради инструмента анализа. Если
сборщик статистики запросов требует перезапуска боевой базы — это несоразмерно;
логи дают почти то же бесплатно.

**Не пытайтесь угадать то, что можно спросить.** Роуты, модели, расписание — у
приложения. Состав служб — у живых контейнеров. Содержимое бесхемных полей — у
данных.

---

# Честный итог

Ценность такого индекса не в том, что он находит больше человека. Проверка на
настоящей задаче: из двадцати девяти затронутых файлов двадцать восемь были
найдены и вручную, тремя раундами чтения. Ценность в том, что **он находит это за
секунду вместо трёх раундов** — и что двадцать девятый он всё-таки нашёл.

И в том, что он называет свои границы. Карта, которая молчит о белых пятнах,
опаснее отсутствия карты: по ней идут уверенно.
