Static checks і безпечний refactoring з Claude Code
Claude швидко перебудовує код. Ціна починається після відповіді, коли потрібно захистити твердження: зовнішній результат залишився тим самим, а структура справді стала простішою.
Розберемо один refactor сервісу розрахунку доставки. Спочатку знайдемо наявні checks і знімемо baseline, потім зафіксуємо два сценарії поведінки, змінимо одну межу й зберемо evidence без фрази "наче безпечно".
- зелений test може бути чесним і все одно недостатнім;
- кожен sensor дозволяє лише обмежене твердження;
- characterization зберігає поточну поведінку, а не оголошує її правильною;
- accepted refactor-step завершується окремим commit;
- якщо evidence бракує, правильний verdict -
INCONCLUSIVE.
Tests green. Refactor unsafe
Виділяючи helper, Claude заодно нормалізував внутрішній Money з об'єкта в число. Targeted tests перевіряють підсумкову fee і повертають 12 passed, а сусідній consumer більше не збирається.
$ npm test -- delivery-quote
12 passed
$ npm run typecheck
checkout-preview.ts:41:18 - error TS2339:
Property 'amountCents' does not exist on type 'number'.
Обидва сигнали правдиві. Тест підтвердив один сценарій поведінки, а type-check виявив порушення публічного контракту. Помилка починається, коли перший висновок розширюють до "change safe".
continue- виправляти поверх і збільшувати невідомий diff;rollback- припустимо, якщо scope уже втрачено;inspect- знайти, чому structural step змінив public type.
Harness - не одна зелена лампа
Formatter прибирає шум форматування з diff, але нічого не стверджує про поведінку. Інші sensors теж не голосують за change: кожен відповідає на своє запитання й має сліпу зону.
| Sensor | Що дозволяє стверджувати | Чого не доводить |
|---|---|---|
| lint / static analysis | налаштовані правила не порушено | бізнес-поведінка коректна |
| type-check | checked type contracts узгоджено | runtime data валідні |
| tests | вибрані scenarios пройшли assertions | інші scenarios безпечні |
| build | потрібний artifact збирається | artifact працює правильно |
| coverage | рядки й гілки виконувалися | assertions достатньо сильні |
| code intelligence | index бачить references і зв'язки | dynamic usage знайдено повністю |
Цінність sensor визначається межею його твердження, а не кольором. П'ять зелених outputs не компенсують один червоний на зачепленій межі.
Спочатку baseline, потім причинність
Запуск checks до першої правки виглядає порожньою формальністю, і цей крок пропускають найчастіше. Дарма: якщо частина перевірок падала ще до правки, after-output не показує, що саме зламав поточний diff. Baseline відокремлює новий failure від старого шуму.
BEFORE AFTER STEP 1
lint PASS PASS
typecheck PASS FAIL TS2339 <- new
tests 12 passed 12 passed
build PASS not run <- no claim
- до Edit запускаємо мінімально релевантний baseline;
- pre-existing failures записуємо окремо;
- після кроку порівнюємо delta, а не весь старий шум;
- немає baseline - verdict
INCONCLUSIVE, а неsafe.
Baseline потрібен для причинності. Без нього червоний output не можна чесно пов'язати з поточною трансформацією.
Claude спочатку знаходить contract проєкту
Запит "перевір як слід" часто породжує новий linter і окреме завдання щодо toolchain. У refactor спочатку виявляють наявні правила, нічого не встановлюючи мовчки.
Перед правкою DeliveryQuoteService:
1. Знайди verification-команди в CLAUDE.md і project scripts.
2. Розклади їх на fast, targeted і broad.
3. Для кожної команди назви ризик, який вона ловить.
4. Познач відсутні або неоднозначні checks.
5. Не встановлюй інструменти, не змінюй конфігурацію, не редагуй код.
Відповідь має бути короткою картою, а не пропозицією перебудувати toolchain:
fast-npm run lint,npm run typecheck;targeted-npm test -- delivery-quote;broad-npm run build, повний test suite;unknown- dependency analysis command не знайдено.
Зону впливу шукають до Edit
До виділення normalizeCarrierRate() Claude повертає підтверджену карту symbols, consumers і tests. Це sensor до зміни: він звужує невідоме раніше, ніж з'явиться diff.
symbol: DeliveryQuoteService.quote
callers: 3 | type consumers: 2 | tests: 2 files
allowed: delivery-quote.ts, delivery-quote.test.ts
uncertainty: runtime plugin calls may be invisible
Суцільні лінії підтверджені index. Пунктир залишається uncertainty: reflection, runtime registration і plugins можуть не потрапити в symbol search.
find usages здається нудною кнопкою рівно до першого consumer, якого інакше знайшли б після PR.Refactoring тримається на одній обіцянці
Refactoring змінює внутрішню структуру без зміни observable behavior. Розмір diff і слово refactor у назві PR нічого не класифікують.
| Намір | Категорія | У цей PR? |
|---|---|---|
| виділити pure helper, зберегти output | refactor | так |
| виправити неправильну fee | bugfix | окреме завдання |
додати discountCode | feature | окреме завдання |
| оновити carrier SDK | migration | окреме завдання |
| переписати модуль повністю | rewrite | інший ризик і план |
Фраза "заодно виправив баг" означає, що поведінку змінено. Зміна може бути корисною, але refactor від цього не стає ширшим - вона стає іншою роботою.
Контракт ширший за return value
Однаковий return value не рятує, якщо змінився public type, порядок side effects або кількість зовнішніх calls. Межу observation називають до Edit, поки результат ще не підштовхує переписати contract під себе.
input shape stays the same
feeCents stays the same
etaDays stays the same
warningCode stays the same
public type stays the same
carrier call stays once
log timing not claimed
- return value бачить безпосередній caller;
- public contract бачать інші modules і API consumers;
- side effects спостерігають database, queue та external services;
- operational signals стають contract, якщо від них залежить support.
Деталь стає observable, коли в неї є consumer, а не коли її видно в коді. Тому debug-log іноді частина contract, а іноді просто внутрішній запис.
Перед refactor фіксуємо два сценарії поведінки
Слово characterization звучить серйозніше, ніж сама робота. Це два звичайні тести, які відповідають не на запитання "як має бути", а на вужче: "що ця функція робить зараз?" Для local refactor цього вистачає: вони захищають два вибрані сценарії від випадкової зміни.
it("keeps the standard quote", () => {
expect(service.quote(standardRequest)).toEqual({
feeCents: 12900,
etaDays: [2, 4],
warningCode: null,
});
});
it("keeps the remote-zone warning", () => {
expect(service.quote(remoteRequest).warningCode)
.toBe("REMOTE_ZONE");
});
Обидва тести мають бути зеленими на поточному коді до production Edit. Вони спостерігають output, а не назву майбутнього helper або кількість внутрішніх branches.
Golden master зберігає й дивацтва
Перший запуск фіксує feeCents: 12900, хоча product owner очікував 11900. Golden master уміє зберегти поточну поведінку, але не додає в assertion бізнес-сенс.
| Що відомо | Рішення |
|---|---|
| значення підтверджене як contract | зберегти в characterization |
| значення виглядає підозріло | позначити uncertainty, не виправляти тут |
| є підтверджений defect | окремий bugfix + regression test |
| output нестабільний | спочатку стабілізувати observation |
Запропонуй не більше двох characterization-тестів
для поточного output.
Production-код не редагуй.
Не вважай зафіксовану поведінку правильною.
Явно познач нестабільні поля й підозрілі значення.
Characterization робить поведінку інваріантом для refactor, а не сертифікатом правильності. Знайдений defect отримує власне завдання.
Safe loop складається з маленьких доказів
Цикл не завершується відчуттям "стало чистіше". Кожне коло повертає рішення: commit, rollback або чесне визнання, що evidence поки недостатньо.
- повний suite може бути надто повільним для кожного Edit;
- baseline може містити known failures;
- прийнятий semantic step стає окремим commit;
- до рішення припустимий тимчасовий оборотний checkpoint.
Prompt задає scope і право зупинитися
Claude отримує одну structural ціль, явні non-goals і умову зупинки. Prompt не доводить безпеку, але робить порушення меж помітним раніше.
Зроби один behavior-preserving refactor step:
виділи normalizeCarrierRate() з DeliveryQuoteService.quote().
Обмеження:
- не змінюй DeliveryQuote і request types;
- не змінюй feeCents, etaDays і warningCode;
- редагуй лише delivery-quote.ts і його test-файл;
- не оновлюй dependencies і не прибирай сторонні warnings;
- запусти наявні delivery-quote tests і typecheck;
- якщо потрібен інший файл або будь-який check упав -
зупинися й поясни причину.
Поверни команди, їхні outputs і короткий summary diff.
Після відповіді читають terminal output і diff, а ключову команду запускають у своєму середовищі. Переможний prose Claude не входить до evidence.
- behavior guardrails захищають output і public types;
- scope guardrails захищають дозволені files і non-goals;
- stop condition не дає моделі виправляти failure поверх незрозумілого state.
Green checks не приймають diff
Три результати показують, чому однаковий green не веде до однакового рішення. Відкривайте картки по черзі й спочатку вибирайте verdict.
A - кандидат на commit
tests PASS, typecheck PASS, 2 files changed
extracted one helper; public type untouched
quote(request: QuoteRequest): DeliveryQuote {
- const rate = raw.rate_cents !== undefined
- ? { amountCents: raw.rate_cents, currency: raw.cur }
- : { amountCents: raw.rateCents, currency: raw.currency };
+ const rate = normalizeCarrierRate(raw);
const fee = this.policy.applyMargin(rate, request.zone);
...
+function normalizeCarrierRate(raw: CarrierRateResponse): Money {
+ return raw.rate_cents !== undefined
+ ? { amountCents: raw.rate_cents, currency: raw.cur }
+ : { amountCents: raw.rateCents, currency: raw.currency };
+}
Кожен рядок можна пояснити за один прохід: логіка нормалізації переїхала в helper без змін, сигнатуру quote() не зачеплено. Такий крок фіксуємо: scope збігся, релевантні sensors зелені.
B - відхилити
tests PASS, typecheck PASS, 9 files changed
renamed DTOs and formatted a neighboring module
Checks зелені, але scope розповзся. Повернути зайве дешевше, ніж пояснювати дев'ять файлів на review.
C - INCONCLUSIVE
tests PASS, typecheck not run
missing signal reported as if it were green
Відсутній signal не стає зеленим. Claim про public type поки не підтверджено.
Checks виявляють класи помилок. Конкретний diff приймає людина. Якщо змінено public symbol, assertion підігнано під result або diff не можна пояснити за один прохід, крок зупиняється.
Архітектурний refactor змінює напрям знання
Новий module означає новий refactor-step: знову фіксуються impact map, baseline і дозволений список files. Лише потім повторюється той самий loop.
Після кроку policy більше не знає форму SDK response: adapter перетворює зовнішній формат на внутрішній input. Публічний DeliveryQuote і спостережувана поведінка залишаються незмінними.
- static evidence - import direction, references, type-check і build;
- behavioral evidence - characterization і relevant integration checks;
- interface з'являється лише там, де робить реальну seam яснішою.
Acceptance перевіряє безпеку й користь
Refactor PR відповідає на два запитання: обіцяна поведінка не змінилася й конкретна structural problem справді зменшилася. Друге запитання забувають частіше - велика перестановка коду легко проходить review просто тому, що нічого не зламала. Одного green suite тут замало.
| Criterion | Evidence | Claim limit |
|---|---|---|
| behavior preserved | 2 characterization-тести + relevant tests | лише зафіксовані сценарії |
| public type preserved | type-check + diff public symbols | лише checked consumers |
| artifact still builds | npm run build | не runtime correctness |
| structural payoff | import map before/after | одна boundary, не ідеальна architecture |
| scope controlled | file list + readable diff | не виключає hidden use |
| rollback cheap | one semantic commit | не відкочує зовнішні side effects |
High coverage залишається покажчиком непройдених областей, а не числовим доказом якості tests. Reviewer не має вгадувати structural payoff за формою diff.
Фінал: evidence замість слова safe
Фінальна картка не обіцяє безпеку взагалі. Вона називає structural goal, збережену поведінку й межу того, чого поточний harness не довів.
STRUCTURAL GOAL
isolate carrier response normalization
PRESERVED BEHAVIOR
feeCents, etaDays, warningCode, public DeliveryQuote
dynamic plugin consumer; production carrier behavior
EVIDENCE
baseline: lint/typecheck/tests/build green
after: lint/typecheck/tests/build green
diff: 2 production files + 1 test file
map: carrier SDK shape ends at adapter
DECISION
ready for review within the stated claim
- знайти harness, зняти baseline і побудувати impact map;
- назвати спостережувану поведінку й додати 1-2 characterization-тести;
- зробити одну transformation, запустити sensors, прочитати diff і commit;
- розширити checks до claim і повернути verdict, включно з
INCONCLUSIVE.
Safe refactor - не властивість відповіді Claude. Це обмежене твердження, яке можна простежити від structural goal через diff до evidence.