Как делать12 октября 2026 г.7 мин чтения
Вайбкодинг: от прототипа к продуктуШаг 3 из 7

Ревью кода, написанного ИИ: на что смотреть в первую очередь

Коротко

Код агента ревьюят по тем же правилам, что и код человека, но начинают с того, что агент знать не мог: кто владеет объектом, какие поля можно менять, какие инварианты держит система. Порядок — от дорогих ошибок к косметике: права доступа, валидация входа, секреты, обработка ошибок, зависимости, дублирование, инварианты и тесты. Зелёные тесты агента не доказательство: проверьте, что они описывают требование, а не повторяют реализацию.

РуководствоУровень: СреднийВайбкодинг: от прототипа к продуктуПрактика: ~1–2 ч

После материала вы сможете

  • пройти дифф агента по чек-листу из восьми областей — от прав доступа до тестов — в порядке убывания цены ошибки
  • найти в учебном обработчике на TypeScript доступ к чужому объекту, mass assignment, проглоченную ошибку, запасной секрет и лишние зависимости
  • исправить обработчик: схема zod вместо приведения типа, проверка владельца, цены из каталога, отказ при сбое
  • написать тесты на требования, которые падают на версии агента и проходят на исправленной

Проверено на

TypeScript 7.0Vitest 5.0zod 4.6Node.js 24 LTS (24.20)Git 2.54

Сверено с документацией: 12 октября 2026 г.

Содержание
  1. 01Почему у кода агента свой порядок ревью
  2. 02Чек-лист: восемь вопросов к изменениям агента
  3. 03Учебный пример: что прислал агент
  4. 04Разбор по чек-листу
  5. 05Исправление: схема, владелец, каталог, честная ошибка
  6. 06Проверка: tsc и тесты на обеих версиях
  7. 07Как встроить ревью в работу и где его предел

Почему у кода агента свой порядок ревью

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

Поэтому ревью идёт от дорогого к дешёвому. Ошибку в правах доступа или в данных находят после релиза пользователи, и она стоит дороже всего; имя переменной можно поправить в любой момент. Руководство Google по код-ревью требует прочитать каждую строку, которую вам назначили, и отдельно проверить тесты: «тесты не проверяют сами себя». Для кода агента это правило важнее вдвойне: тест и реализацию написал один и тот же автор с одними и теми же допущениями.

Чек-лист: восемь вопросов к изменениям агента

Права и владение объектом

Вопрос ревьюера
Проверяет ли сервер, что пользователь вправе менять именно этот объект?
Сигнал в диффе
Пользователь передан в функцию, но не используется; запрос к базе по одному id

Валидация входа

Вопрос ревьюера
Проверены ли тип, диапазон и состав полей на сервере?
Сигнал в диффе
as SomeType, JSON.parse без схемы, Object.assign(entity, body)

Секреты и конфигурация

Вопрос ревьюера
Нет ли ключей и запасных значений в коде и логах?
Сигнал в диффе
process.env.X ?? "…", ключ в тесте, console.log конфигурации

Обработка ошибок

Вопрос ревьюера
Видит ли вызывающий код сбой?
Сигнал в диффе
catch, который возвращает успех или пустое значение

Зависимости

Вопрос ревьюера
Нужен ли новый пакет, существует ли он, поддерживается ли?
Сигнал в диффе
Новые строки в package.json; пакет не импортируется в коде

Дублирование

Вопрос ревьюера
Нет ли в проекте готовой функции для того же?
Сигнал в диффе
Своя формула рядом с существующей утилитой

Инварианты

Вопрос ревьюера
Какие правила данных изменение может нарушить?
Сигнал в диффе
Поле, которое должен вычислять сервер, приходит с клиента; статусы меняются без проверки

Тесты

Вопрос ревьюера
Проверяют ли тесты требование, включая отказы?
Сигнал в диффе
Один тест на успешный сценарий, данные подогнаны под реализацию

Первые три строки чек-листа совпадают с самыми частыми рисками веб-приложений: в OWASP Top 10:2025 нарушение контроля доступа стоит на первом месте, и среди его примеров — доступ к чужой записи по её идентификатору. Остальные строки ловят ошибки, которые не обязательно уязвимы, но делают систему непредсказуемой.

Учебный пример: что прислал агент

Задача агенту: «сделай обработчик PATCH /orders/:id, который меняет состав заказа». В проекте уже есть типы и функция подсчёта итога cartTotal в src/pricing.ts. Типы задают и инвариант: итог равен сумме price * qty, а цены берутся из каталога.

src/types.ts
export type User = { id: string; role: "customer" | "admin" };

// Prices are integers in minor currency units (tiyn)
export type LineItem = { sku: string; price: number; qty: number };

export type Order = {
  id: string;
  ownerId: string;
  status: "draft" | "paid";
  items: LineItem[];
  // Invariant: total = sum of price * qty, prices come from the catalog
  total: number;
};

export interface OrderRepo {
  find(id: string): Promise<Order | undefined>;
  save(order: Order): Promise<void>;
}

export interface Catalog {
  priceOf(sku: string): number | undefined;
}

export type Result = { status: number; body: unknown };
Общие типы проекта; цены — целые числа в тиынах, чтобы не было ошибок округления.

Агент прислал обработчик, конфигурацию и тест. Пример учебный: дефекты собраны в одном небольшом файле намеренно, по одному на каждую строку чек-листа. Но каждый из них по отдельности выглядит правдоподобно и проходит компиляцию.

src/orders.defective.ts
import _ from "lodash";
import type { Order, OrderRepo, Result, User } from "./types.js";

// PATCH /orders/:id — update the items of an order
export async function updateOrder(
  _user: User,
  orderId: string,
  input: unknown,
  repo: OrderRepo,
): Promise<Result> {
  try {
    const body = input as Partial<Order>;
    const order = await repo.find(orderId);
    if (!order) return { status: 404, body: { error: "not_found" } };

    const updated = _.cloneDeep(order);
    Object.assign(updated, body);
    updated.total = updated.items.reduce((sum, item) => sum + item.price * item.qty, 0);

    await repo.save(updated);
    console.log("order updated", updated);
    return { status: 200, body: updated };
  } catch (e) {
    console.error(e);
    return { status: 200, body: { ok: true } };
  }
}
Версия агента. Компилируется под strict и noUnusedParameters.
src/config.defective.ts
export const config = {
  paymentsApiKey: process.env.PAYMENTS_API_KEY ?? "demo-payments-key-123",
  debug: true,
};
test/orders.agent.test.ts
// The test the agent wrote together with orders.defective.ts
import { expect, it } from "vitest";
import { updateOrder } from "../src/orders.defective.js";
import type { Order, OrderRepo } from "../src/types.js";

it("updates order", async () => {
  const saved: Order[] = [];
  const repo: OrderRepo = {
    find: async () => ({ id: "o-1", ownerId: "u-1", status: "draft", items: [], total: 0 }),
    save: async (order) => {
      saved.push(order);
    },
  };
  const input = { items: [{ sku: "SKU-CUP", price: 90000, qty: 2 }] };
  const res = await updateOrder({ id: "u-1", role: "customer" }, "o-1", input, repo);
  expect(res.status).toBe(200);
});
Тест агента: проходит на его версии.

Разбор по чек-листу

Начинаем с картины изменений и зависимостей: это быстро и сразу задаёт вопросы к коду.

terminal
$ git diff --stat HEAD~1
 package.json              |  3 +++
 src/config.defective.ts   |  4 ++++
 src/orders.defective.ts   | 27 +++++++++++++++++++++++++++
 test/orders.agent.test.ts | 17 +++++++++++++++++
 4 files changed, 51 insertions(+)
$ git diff HEAD~1 -- package.json
diff --git a/package.json b/package.json
index e9b512e..356e132 100644
--- a/package.json
+++ b/package.json
@@ -7,9 +7,12 @@
     "test": "vitest run"
   },
   "dependencies": {
+    "lodash": "^4.18.1",
+    "request": "^2.88.2",
     "zod": "^4.6.6"
   },
   "devDependencies": {
+    "@types/lodash": "^4.17.26",
     "@types/node": "^26.6.5",
     "typescript": "^7.0.2",
     "vitest": "^5.0.4"
$ npm view request deprecated
request has been deprecated, see https://github.com/request/request/issues/3142
$ npm view lodash version
4.18.1
$ git grep -n -e "\"request\"" -- src || echo "request: no usages in src"
request: no usages in src
$ git grep -n "lodash" -- src
src/orders.defective.ts:1:import _ from "lodash";
Node.js 24.20, TypeScript 7.0, Vitest 5.0, контейнер node:24-alpine (реальный вывод). Сообщение об устаревании пакета request — из реестра npm.

Агент добавил три пакета. request объявлен устаревшим в реестре — команда npm deprecate для того и существует, чтобы предупреждать всех, кто его ставит, — и к тому же нигде не импортируется. lodash нужен ради одного cloneDeep. Теперь сам код, строка за строкой:

_user: User

Что не так
Пользователь не используется. Подчёркивание в имени отключает проверку noUnusedParameters — компилятор промолчал, а проверки владельца нет
Чем грозит
Любой вошедший пользователь меняет любой заказ, перебирая id

input as Partial<Order>

Что не так
Приведение типа, а не проверка: по документации TypeScript утверждения типа удаляются при компиляции и во время выполнения ничего не проверяют
Чем грозит
Количество 0, −1 или 1,5 проходит в базу

Object.assign(updated, body)

Что не так
Mass assignment: в заказ копируются любые поля запроса, включая ownerId, status и price
Чем грозит
Клиент передаёт заказ другому, помечает его оплаченным или ставит цену 1

item.price * item.qty

Что не так
Итог считается по цене из запроса и своей формулой, мимо cartTotal
Чем грозит
Нарушен инвариант цены; при изменении правил подсчёта две формулы разойдутся

Нет проверки status

Что не так
Оплаченный заказ можно менять
Чем грозит
Состав заказа расходится с полученной оплатой

catch → 200 { ok: true }

Что не так
Сбой базы выглядит как успех
Чем грозит
Клиент уверен, что заказ сохранён; ошибку никто не увидит

console.log("order updated", updated)

Что не так
В лог уходит весь заказ
Чем грозит
Персональные данные в логах и лишний шум

?? "demo-payments-key-123"

Что не так
Запасное значение секрета в коде
Чем грозит
Приложение молча стартует без настоящего ключа; строка попадает в репозиторий

orders.agent.test.ts

Что не так
Тест передаёт цену в запросе и проверяет только статус 200
Чем грозит
Тест закрепляет дефект вместо требования

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

Исправление: схема, владелец, каталог, честная ошибка

src/orders.ts
import { z } from "zod";
import { cartTotal } from "./pricing.js";
import type { Catalog, LineItem, Order, OrderRepo, Result, User } from "./types.js";

// Only these fields may come from the client: no price, owner, status or total
const UpdateOrderInput = z.strictObject({
  items: z
    .array(z.strictObject({ sku: z.string().min(1).max(64), qty: z.int().min(1).max(100) }))
    .min(1)
    .max(50),
});

// PATCH /orders/:id — update the items of a draft order
export async function updateOrder(
  user: User,
  orderId: string,
  input: unknown,
  repo: OrderRepo,
  catalog: Catalog,
): Promise<Result> {
  const parsed = UpdateOrderInput.safeParse(input);
  if (!parsed.success) return { status: 400, body: { error: "invalid_input" } };

  const order = await repo.find(orderId);
  // Someone else's order looks exactly like a missing one
  if (!order || (order.ownerId !== user.id && user.role !== "admin")) {
    return { status: 404, body: { error: "not_found" } };
  }
  if (order.status !== "draft") return { status: 409, body: { error: "order_not_editable" } };

  const items: LineItem[] = [];
  for (const { sku, qty } of parsed.data.items) {
    const price = catalog.priceOf(sku);
    if (price === undefined) return { status: 400, body: { error: "unknown_sku", sku } };
    items.push({ sku, price, qty });
  }

  const updated: Order = { ...order, items, total: cartTotal(items) };
  await repo.save(updated); // a failure propagates to the shared error handler (500)
  return { status: 200, body: updated };
}
Исправленный обработчик. Пакеты lodash и request больше не нужны.
  • Вход проверяет схема zod: z.strictObject по документации отклоняет незнакомые ключи, поэтому price, ownerId и status из запроса дают 400, а не тихо отбрасываются. Это и есть защита от mass assignment, которую рекомендует OWASP: явный список разрешённых полей.
  • Количество — целое от 1 до 100, позиций — от 1 до 50. Границы задают бизнес-правила; здесь они учебные.
  • Чужой заказ возвращается как несуществующий (404), чтобы не подтверждать, что такой id есть. Подробно о 401, 403 и 404 — в уроке об авторизации.
  • Цена берётся из каталога, итог — из общей cartTotal. Неизвестный артикул — 400.
  • Оплаченный заказ не меняется — 409.
  • Ошибка сохранения не перехватывается: её получит общий обработчик, ответит 500 и запишет в лог без данных заказа.
src/config.ts
function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required environment variable ${name}`);
  return value;
}

// Called once at startup: no key, no start — instead of a silent fallback
export function loadConfig() {
  return {
    paymentsApiKey: requireEnv("PAYMENTS_API_KEY"),
    debug: process.env.DEBUG === "1",
  };
}
Без ключа приложение не стартует — это видно сразу, а не на первом платеже.

Проверка: tsc и тесты на обеих версиях

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

test/orders.test.ts
import { describe, expect, it, vi } from "vitest";
import type { Catalog, Order, OrderRepo, User } from "../src/types.js";

// TARGET=defective: the agent's original version, TARGET=solution: the exercise solution
const { updateOrder }: typeof import("../src/orders.js") =
  process.env.TARGET === "defective"
    ? await import("../src/orders.defective.js")
    : process.env.TARGET === "solution"
      ? await import("../src/orders.solution.js")
      : await import("../src/orders.js");

const alice: User = { id: "u-alice", role: "customer" };
const bob: User = { id: "u-bob", role: "customer" };
const prices: Record<string, number> = { "SKU-TEA": 150000, "SKU-CUP": 90000 };
const catalog: Catalog = { priceOf: (sku) => prices[sku] };

class MemoryRepo implements OrderRepo {
  orders = new Map<string, Order>();
  async find(id: string) {
    const order = this.orders.get(id);
    return order && structuredClone(order);
  }
  async save(order: Order) {
    this.orders.set(order.id, structuredClone(order));
  }
}

function setup(status: Order["status"] = "draft") {
  const repo = new MemoryRepo();
  repo.orders.set("o-1", {
    id: "o-1",
    ownerId: alice.id,
    status,
    items: [{ sku: "SKU-TEA", price: 150000, qty: 1 }],
    total: 150000,
  });
  return repo;
}

describe("updateOrder", () => {
  it("lets the owner change items and prices them from the catalog", async () => {
    const repo = setup();
    const res = await updateOrder(alice, "o-1", { items: [{ sku: "SKU-CUP", qty: 2 }] }, repo, catalog);
    expect(res.status).toBe(200);
    expect(repo.orders.get("o-1")?.total).toBe(180000);
  });

  it("hides another customer's order", async () => {
    const repo = setup();
    const res = await updateOrder(bob, "o-1", { items: [{ sku: "SKU-CUP", qty: 1 }] }, repo, catalog);
    expect(res.status).toBe(404);
    expect(repo.orders.get("o-1")?.items[0].sku).toBe("SKU-TEA");
  });

  it("rejects a price sent by the client", async () => {
    const repo = setup();
    const res = await updateOrder(alice, "o-1", { items: [{ sku: "SKU-CUP", qty: 1, price: 1 }] }, repo, catalog);
    expect(res.status).toBe(400);
    expect(repo.orders.get("o-1")?.total).toBe(150000);
  });

  it("does not let the client change owner or status", async () => {
    const repo = setup();
    const input = { items: [{ sku: "SKU-CUP", qty: 1 }], ownerId: bob.id, status: "paid" };
    const res = await updateOrder(alice, "o-1", input, repo, catalog);
    expect(res.status).toBe(400);
    expect(repo.orders.get("o-1")?.ownerId).toBe(alice.id);
  });

  it.each([0, -1, 1.5, 101])("rejects quantity %s", async (qty) => {
    const res = await updateOrder(alice, "o-1", { items: [{ sku: "SKU-CUP", qty }] }, setup(), catalog);
    expect(res.status).toBe(400);
  });

  it("rejects an unknown SKU", async () => {
    const res = await updateOrder(alice, "o-1", { items: [{ sku: "SKU-GOLD", qty: 1 }] }, setup(), catalog);
    expect(res.status).toBe(400);
  });

  it("refuses to edit a paid order", async () => {
    const res = await updateOrder(alice, "o-1", { items: [{ sku: "SKU-CUP", qty: 1 }] }, setup("paid"), catalog);
    expect(res.status).toBe(409);
  });

  it("does not report success when saving fails", async () => {
    const repo = setup();
    vi.spyOn(repo, "save").mockRejectedValue(new Error("db is down"));
    await expect(
      updateOrder(alice, "o-1", { items: [{ sku: "SKU-CUP", qty: 1 }] }, repo, catalog),
    ).rejects.toThrow("db is down");
  });
});
terminal
$ npx tsc --noEmit -p . && echo "tsc: no errors"
tsc: no errors
$ npx vitest run test/orders.agent.test.ts 2>&1 | grep -E "✓|×|Tests "
 ✓ test/orders.agent.test.ts (1 test) 5ms
      Tests  1 passed (1)
$ TARGET=defective npx vitest run test/orders.test.ts --reporter=verbose 2>&1 | grep -E "^ +(✓|×)|Tests "
 × test/orders.test.ts > updateOrder > lets the owner change items and prices them from the catalog 12ms
 × test/orders.test.ts > updateOrder > hides another customer's order 1ms
 × test/orders.test.ts > updateOrder > rejects a price sent by the client 1ms
 × test/orders.test.ts > updateOrder > does not let the client change owner or status 1ms
 × test/orders.test.ts > updateOrder > rejects quantity 0 1ms
 × test/orders.test.ts > updateOrder > rejects quantity -1 1ms
 × test/orders.test.ts > updateOrder > rejects quantity 1.5 1ms
 × test/orders.test.ts > updateOrder > rejects quantity 101 0ms
 × test/orders.test.ts > updateOrder > rejects an unknown SKU 1ms
 × test/orders.test.ts > updateOrder > refuses to edit a paid order 1ms
 × test/orders.test.ts > updateOrder > does not report success when saving fails 4ms
⎯⎯⎯⎯⎯⎯ Failed Tests 11 ⎯⎯⎯⎯⎯⎯⎯
      Tests  11 failed (11)
$ npx vitest run test/orders.test.ts test/config.test.ts --reporter=verbose 2>&1 | grep -E "^ +(✓|×)|Tests "
 ✓ test/config.test.ts > fails fast without the payments key 3ms
 ✓ test/config.test.ts > reads the key from the environment 0ms
 ✓ test/orders.test.ts > updateOrder > lets the owner change items and prices them from the catalog 4ms
 ✓ test/orders.test.ts > updateOrder > hides another customer's order 0ms
 ✓ test/orders.test.ts > updateOrder > rejects a price sent by the client 0ms
 ✓ test/orders.test.ts > updateOrder > does not let the client change owner or status 0ms
 ✓ test/orders.test.ts > updateOrder > rejects quantity 0 0ms
 ✓ test/orders.test.ts > updateOrder > rejects quantity -1 0ms
 ✓ test/orders.test.ts > updateOrder > rejects quantity 1.5 0ms
 ✓ test/orders.test.ts > updateOrder > rejects quantity 101 0ms
 ✓ test/orders.test.ts > updateOrder > rejects an unknown SKU 1ms
 ✓ test/orders.test.ts > updateOrder > refuses to edit a paid order 0ms
 ✓ test/orders.test.ts > updateOrder > does not report success when saving fails 2ms
      Tests  13 passed (13)
Node.js 24.20, TypeScript 7.0, Vitest 5.0, контейнер node:24-alpine (реальный вывод). grep оставляет из отчёта Vitest только строки с результатами.

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

Как встроить ревью в работу и где его предел

  • В задаче агенту перечислены инварианты и роли: кто что может менять, какие поля вычисляет сервер
  • Компилятор в строгом режиме, линтер и тесты запускаются в CI на каждом pull request
  • Новые зависимости проверяются в реестре: существует ли пакет под этим именем, не устарел ли, импортируется ли
  • В CI работает поиск секретов; запасных значений секретов в коде нет
  • Для каждого изменения прав или данных есть тест на отказ: чужой объект, лишнее поле, неверный диапазон
  • Ревьюер прочитал каждую строку диффа, а не только отчёт агента

Ограничения стоит назвать прямо. Ревью диффа видит только изменённые строки: оно не найдёт дыру в коде, который никто не трогал, не оценит архитектуру целиком и не заменяет проверку безопасности. Для веб-приложения ориентиром служит OWASP ASVS (актуальная версия — 5.0.0), но соответствие стандарту ревью одного pull request не подтверждает. Если продукт собран с ассистентом быстро и целиком, разумно один раз проверить его по доменам — доступы, данные, секреты, тесты, сборку, — а дальше держать планку ревью на каждом изменении.

Как проверено4 · пройденосверено 12 октября 2026 г.
  • запущено, работает

    src/orders.defective.ts, src/config.defective.ts, test/orders.agent.test.ts (учебная версия агента)

    tsc --noEmit (TypeScript 7.0.2, strict, noUnusedParameters) без ошибок; тест агента проходит (Vitest 5.0.4); те же 11 тестов требований на этой версии падают все (TARGET=defective) — причины в out/defective-failures-windows.txt: NaN вместо итога, 200 вместо 404/400/409, resolved вместо rejected

  • запущено, работает

    src/orders.ts, src/config.ts, test/orders.test.ts, test/config.test.ts (исправленная версия)

    docker run --rm node:24-alpine (Node.js 24.20), npm ci, zod 4.6.6: tsc --noEmit без ошибок, vitest — 13 passed; вывод в out/1-checks.txt

  • запущено, работает

    Разбор зависимостей: git diff, npm view request deprecated, npm view lodash version, git grep

    lab.sh в том же контейнере, Git 2.54; репозиторий из двух коммитов (база и коммит «агента»), вывод в out/0-deps.txt; ответ реестра npm на 12.10.2026

  • запущено, работает

    Упражнение «измени»: refine для повторяющихся SKU (src/orders.solution.ts, test/duplicate-sku.test.ts)

    тест падает на src/orders.ts (1 failed) и проходит на решении вместе с основным набором (TARGET=solution, 12 passed); out/2-exercise.txt

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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