Код агента ревьюят по тем же правилам, что и код человека, но начинают с того, что агент знать не мог: кто владеет объектом, какие поля можно менять, какие инварианты держит система. Порядок — от дорогих ошибок к косметике: права доступа, валидация входа, секреты, обработка ошибок, зависимости, дублирование, инварианты и тесты. Зелёные тесты агента не доказательство: проверьте, что они описывают требование, а не повторяют реализацию.
После материала вы сможете
- пройти дифф агента по чек-листу из восьми областей — от прав доступа до тестов — в порядке убывания цены ошибки
- найти в учебном обработчике на TypeScript доступ к чужому объекту, mass assignment, проглоченную ошибку, запасной секрет и лишние зависимости
- исправить обработчик: схема
zodвместо приведения типа, проверка владельца, цены из каталога, отказ при сбое - написать тесты на требования, которые падают на версии агента и проходят на исправленной
Что знать заранее
Проверено на
TypeScript 7.0Vitest 5.0zod 4.6Node.js 24 LTS (24.20)Git 2.54
Сверено с документацией: 12 октября 2026 г.
Содержание
Почему у кода агента свой порядок ревью
Сгенерированный код обычно аккуратен: он компилируется, отформатирован, к нему приложены тесты, и они проходят. Это не значит, что он плох, и не значит, что он верен. Типичные ошибки агента — не синтаксис, а недостающий контекст. Агент не знает, что цены берутся из каталога, что оплаченный заказ нельзя менять и что в проекте уже есть функция подсчёта итога, — если этого нет в коде, в задаче или в файле инструкций.
Поэтому ревью идёт от дорогого к дешёвому. Ошибку в правах доступа или в данных находят после релиза пользователи, и она стоит дороже всего; имя переменной можно поправить в любой момент. Руководство 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, а цены берутся из каталога.
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 };Агент прислал обработчик, конфигурацию и тест. Пример учебный: дефекты собраны в одном небольшом файле намеренно, по одному на каждую строку чек-листа. Но каждый из них по отдельности выглядит правдоподобно и проходит компиляцию.
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 } };
}
}export const config = {
paymentsApiKey: process.env.PAYMENTS_API_KEY ?? "demo-payments-key-123",
debug: true,
};// 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);
});Разбор по чек-листу
Начинаем с картины изменений и зависимостей: это быстро и сразу задаёт вопросы к коду.
$ 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";Агент добавил три пакета. 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. Компилятор в строгом режиме и тест агента проходят — дефекты находятся только сравнением кода с требованиями, которые знаете вы, а не агент.
Исправление: схема, владелец, каталог, честная ошибка
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 };
}- Вход проверяет схема
zod:z.strictObjectпо документации отклоняет незнакомые ключи, поэтомуprice,ownerIdиstatusиз запроса дают 400, а не тихо отбрасываются. Это и есть защита от mass assignment, которую рекомендует OWASP: явный список разрешённых полей. - Количество — целое от 1 до 100, позиций — от 1 до 50. Границы задают бизнес-правила; здесь они учебные.
- Чужой заказ возвращается как несуществующий (404), чтобы не подтверждать, что такой
idесть. Подробно о 401, 403 и 404 — в уроке об авторизации. - Цена берётся из каталога, итог — из общей
cartTotal. Неизвестный артикул — 400. - Оплаченный заказ не меняется — 409.
- Ошибка сохранения не перехватывается: её получит общий обработчик, ответит 500 и запишет в лог без данных заказа.
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 позволяет запустить те же тесты на версии агента и убедиться, что они действительно ловят дефекты.
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");
});
});$ 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)Тест агента зелёный на его версии. Наши одиннадцать тестов на той же версии падают все, включая успешный сценарий: итог получается 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.