TypeScript Compiler API, ts-morph и кодмоды: автоматизация над кодовой базой
0%

Compiler API, ts-morph и кодмоды: автоматизация над кодовой базой

Compiler API, ts-morph и кодмоды: автоматизация над кодовой базой

Рано или поздно наступает задача, которую нельзя решить ни типом, ни ревью: «переименовать поле во всех 3 000 файлов», «запретить process.env вне config.ts», «проставить явные типы возврата у всех экспортов», «перевести кодовую базу с enum на as const». Руками — недели и неизбежные пропуски. Регулярками — быстро, но неверно: текст не знает про области видимости, строки и комментарии.

Правильный инструмент — тот же, что уже стоит в проекте. TypeScript публикует Compiler API: вы получаете полноценное дерево разбора и, что важнее, доступ к чекеру типов. Это превращает разовый героизм в скрипт на сто строк.

Как устроен компилятор

Ключ к пониманию — разделение уровней:

  • AST (Parser) отвечает на вопрос «как это написано»: Identifier, CallExpression, PropertyAccessExpression. Синтаксис, ничего больше.
  • Символы (Binder) отвечают на вопрос «на что ссылается это имя»: связывают использование с объявлением, даже через реэкспорты.
  • Типы (Checker) отвечают на вопрос «что это по сути»: string | undefined, результат вывода дженерика, разрешённая перегрузка.

Кодмод, работающий только на AST, — это умный sed. Кодмод, спрашивающий чекер, — инструмент, который отличает user.id (тип UserId) от session.id (тип string) и правит только то, что нужно.

Соответствие исходного текста и узлов AST с позициями

Каждый узел — объект с полями kind (значение перечисления ts.SyntaxKind), pos и end (границы в исходном тексте), ссылками на детей. Прежде чем писать код, откройте TypeScript AST Viewer и посмотрите, во что разбирается интересующая вас конструкция: это экономит часы.

Собственный анализатор на Compiler API

Начнём с задачи из прошлой главы: найти экспортируемые функции без явного типа возврата — они замедляют компиляцию и делают публичный контракт неявным.

// scripts/find-implicit-returns.ts
import ts from "typescript";

// 1. Собираем программу так же, как это делает tsc: из tsconfig
const configPath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, "tsconfig.json");
if (!configPath) throw new Error("tsconfig.json не найден");

const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, process.cwd());
const program = ts.createProgram(parsed.fileNames, parsed.options);
const checker = program.getTypeChecker(); // доступ к типам — главная ценность

let found = 0;

for (const sourceFile of program.getSourceFiles()) {
  // пропускаем чужой код и файлы деклараций
  if (sourceFile.isDeclarationFile || sourceFile.fileName.includes("node_modules")) continue;

  // 2. Обходим дерево рекурсивно
  ts.forEachChild(sourceFile, function visit(node) {
    if (ts.isFunctionDeclaration(node) && node.name && !node.type) {
      const isExported = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
      if (isExported) {
        // 3. Спрашиваем чекер, что компилятор вывел сам
        const signature = checker.getSignatureFromDeclaration(node);
        const returnType = signature
          ? checker.typeToString(checker.getReturnTypeOfSignature(signature))
          : "unknown";

        const { line } = sourceFile.getLineAndCharacterOfPosition(node.name.getStart());
        console.log(`${sourceFile.fileName}:${line + 1}  ${node.name.text} -> ${returnType}`);
        found++;
      }
    }
    ts.forEachChild(node, visit);
  });
}

console.log(`Всего: ${found}`);
process.exit(found > 0 ? 1 : 0); // можно поставить гейтом в CI

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

Чекер умеет и большее — например, отдать все диагностики программы, как это делает tsc --noEmit:

const diagnostics = ts.getPreEmitDiagnostics(program);
for (const d of diagnostics) {
  console.log(ts.flattenDiagnosticMessageText(d.messageText, "\n"));
}

Кодмоды на ts-morph

Compiler API прекрасен для чтения, но неудобен для записи: AST иммутабелен, изменения делаются через трансформеры и фабрики узлов. Для правок берут ts-morph — обёртку, которая даёт мутабельный API поверх того же компилятора и сама печатает результат обратно в файл.

// scripts/codemod-enum-to-const.ts
import { Project, SyntaxKind } from "ts-morph";

const project = new Project({ tsConfigFilePath: "tsconfig.json" });

for (const file of project.getSourceFiles("src/**/*.ts")) {
  for (const enumDecl of file.getEnums()) {
    // Берём только строковые enum: их безопасно заменить объектом as const
    const members = enumDecl.getMembers().map((m) => ({
      name: m.getName(),
      value: m.getInitializer()?.getText(),
    }));
    if (members.some((m) => m.value === undefined || !m.value.startsWith('"'))) continue;

    const name = enumDecl.getName();
    const isExported = enumDecl.isExported();
    const body = members.map((m) => `  ${m.name}: ${m.value},`).join("\n");

    // Заменяем объявление: объект as const + одноимённый тип-union
    enumDecl.replaceWithText(
      `${isExported ? "export " : ""}const ${name} = {\n${body}\n} as const;\n\n` +
      `${isExported ? "export " : ""}type ${name} = (typeof ${name})[keyof typeof ${name}];`,
    );
  }
}

await project.save(); // записываем изменения на диск

Отдельно стоит безопасное переименование: ts-morph умеет дёргать Language Service, тот самый, что стоит за «Rename Symbol» в редакторе. Оно учитывает области видимости и реэкспорты, а не совпадение подстроки:

const prop = project
  .getSourceFileOrThrow("src/domain/user.ts")
  .getInterfaceOrThrow("User")
  .getPropertyOrThrow("userName");

prop.rename("name");   // обновит ВСЕ использования во всей программе
await project.save();

Ещё несколько задач, которые решаются в пару десятков строк: расставить import type там, где импорт используется только в типах; добавить override после включения noImplicitOverride; заменить относительные импорты на subpath imports из главы про модули; сгенерировать фикстуры по интерфейсам.

Альтернативы: jscodeshift — исторический стандарт для JS-кодмодов (React публикует свои миграции именно так), но он работает без типов; ast-grep — очень быстрый поиск и замена по синтаксическим шаблонам, отличный выбор, когда типы не нужны.

Раскатка кодмода без боли

Механическое изменение тысячи файлов — это риск. Процесс, который работает:

Почему именно так:

  • Скрипт коммитится отдельно — ревьюер читает сто строк логики, а не десять тысяч строк диффа.
  • Прогон кодмода — отдельный коммит без ручных правок. Тогда изменение воспроизводимо: результат можно получить заново из предыдущего коммита.
  • Форматирование — своим коммитом, иначе шум перекроет смысл.
  • Гейт — typecheck и тесты, а не чтение диффа глазами. Именно ради этого момента писались тесты в главе про тестирование.
  • Мержить целиком и быстро: кодмод конфликтует со всеми открытыми ветками, и чем дольше он живёт, тем дороже.

Для очень больших кодовых баз применяют то же по частям: кодмод, умеющий работать с подкаталогом, прогоняется помодульно, каждый прогон — свой PR.

Правила ESLint с доступом к типам

Кодмод меняет код один раз. Чтобы правило соблюдалось дальше, нужен линтер. typescript-eslint даёт правилам доступ к чекеру — это позволяет писать проверки, невозможные в обычном ESLint.

// tools/eslint-rules/no-raw-env.ts
import { ESLintUtils, type TSESTree } from "@typescript-eslint/utils";

const createRule = ESLintUtils.RuleCreator(
  (name) => `https://wiki.acme.dev/eslint/${name}`,
);

export const noRawEnv = createRule({
  name: "no-raw-env",
  meta: {
    type: "problem",
    docs: {
      description:
        "process.env читается только в config.ts, где значения валидируются схемой",
    },
    messages: {
      rawEnv: "Не читайте process.env напрямую — используйте импорт из config.ts",
    },
    schema: [],
  },
  defaultOptions: [],
  create(context) {
    // в config.ts правило не действует
    if (context.filename.endsWith("/config.ts")) return {};

    return {
      // ищем узел вида process.env
      "MemberExpression[object.name='process'][property.name='env']"(
        node: TSESTree.MemberExpression,
      ) {
        context.report({ node, messageId: "rawEnv" });
      },
    };
  },
});

Пример с типовой информацией — правило, запрещающее передавать доменные сущности в HTTP-ответ напрямую (нарушение границы DTO из главы про контракты):

create(context) {
  const services = ESLintUtils.getParserServices(context); // доступ к чекеру
  return {
    CallExpression(node) {
      const arg = node.arguments[0];
      if (!arg) return;
      const type = services.getTypeAtLocation(arg);
      const name = type.getSymbol()?.getName();
      if (name && name.endsWith("Entity")) {
        context.report({ node: arg, messageId: "entityLeak" });
      }
    },
  };
}

Правила подключаются как локальный плагин (плоский конфиг ESLint позволяет подключить объект прямо из файла), и с этого момента конвенция команды перестаёт быть строчкой в вики и становится проверкой в CI. Дополните её --fix-функцией, если исправление механическое, — тогда правило само чинит код.

Генерация кода: когда она оправдана

Кодогенерация — родственник кодмода: тот же AST, только на выходе новый файл. Оправданные случаи мы уже видели: типы из схемы API, клиент БД из схемы (Prisma), .d.ts для публикуемого пакета. Общий признак: есть внешний источник правды, а код — его производная.

Неоправданные: генерация «шаблонного кода», который потом правят руками (при следующей генерации правки потеряются), и генерация того, что элементарно выражается дженериком или функцией. Прежде чем писать генератор, проверьте, не решается ли задача обычной абстракцией.

Правила гигиены для генерируемого кода: отдельный каталог src/generated, баннер // Сгенерировано автоматически, не редактировать в шапке, исключение из линтера и из ревью (.gitattributes с linguist-generated=true), а также проверка «результат генерации совпадает с закоммиченным» в CI.

Карта задач: чем что решать

Когда автоматизация не нужна

Честный список ситуаций, где скрипт дороже ручной работы:

  • Правок меньше сотни и они не повторяются — Rename Symbol в редакторе быстрее.
  • Изменение требует понимания смысла (переименование по домену, разделение функции) — автоматика сделает механически неверно.
  • Конструкция редкая и разнообразная — вы потратите время на разбор частных случаев, которых пять.
  • Кодмод придётся поддерживать: разовый скрипт живёт в PR и умирает вместе с ним, и это нормально. Не превращайте его в «платформу».

Ориентир: если изменение затрагивает больше двухсот мест или должно повторяться регулярно — пишите скрипт. Если меньше и разово — делайте руками.

Типичные ошибки

  • Регулярки вместо AST. Ломают строки, комментарии, шаблонные литералы и совпадения в чужих названиях.
  • Кодмод без прогона форматтера — гигантский шумный дифф.
  • Смешать кодмод и ручные правки в одном коммите — невозможно перепроверить.
  • Игнорировать .d.ts и node_modules при обходе программы — скрипт будет идти вечно и найдёт чужое.
  • Использовать getText() вместо чекера, чтобы «понять тип» — это снова текстовая эвристика.
  • Оставить правило только в вики. Если конвенцию не проверяет CI, она соблюдается ровно до следующего аврала.

Источники

Итог продвинутого блока

Пять последних глав закрывали то, что отделяет «пишу на TypeScript» от «веду на TypeScript большую кодовую базу»: как типы и код путешествуют между пакетами (модули и декларации); как устроена объектная механика языка и его декораторы (классы); как не дать контракту между сервисами разъехаться (API-контракты); как затащить TypeScript в живой проект и не утонуть в компиляции (миграция и производительность); и как применять решения ко всей кодовой базе разом — эта глава.

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

Что дальше

Курс пройден целиком. Вернитесь к обзору и дорожной карте, чтобы освежить любой раздел. Дальше логично углубиться в смежные треки: фронтенд-архитектуру — если ваш TypeScript живёт в браузере; тестирование — чтобы выстроить стратегию проверок; безопасность цепочки поставок — потому что npm-зависимости остаются главной поверхностью атаки любого TypeScript-проекта.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов