Static checks і безпечний refactoring з Claude Code

Claude швидко перебудовує код. Ціна починається після відповіді, коли потрібно захистити твердження: зовнішній результат залишився тим самим, а структура справді стала простішою.

Розберемо один refactor сервісу розрахунку доставки. Спочатку знайдемо наявні checks і знімемо baseline, потім зафіксуємо два сценарії поведінки, змінимо одну межу й зберемо evidence без фрази "наче безпечно".

Сьогодні пройдемо: рівень 20. Static checks і безпечний refactoring з Claude Code. Git, diff і targeted tests вважаються робочим тлом.

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".

Ваш хід: який із трьох варіантів єдиний не втрачає інформацію про те, що сталося? Вирішіть, перш ніж гортати далі.

Harness - не одна зелена лампа

Formatter прибирає шум форматування з diff, але нічого не стверджує про поведінку. Інші sensors теж не голосують за change: кожен відповідає на своє запитання й має сліпу зону.

SensorЩо дозволяє стверджуватиЧого не доводить
lint / static analysisналаштовані правила не порушенобізнес-поведінка коректна
type-checkchecked type contracts узгодженоruntime data валідні
testsвибрані scenarios пройшли assertionsінші scenarios безпечні
buildпотрібний artifact збираєтьсяartifact працює правильно
coverageрядки й гілки виконувалисяassertions достатньо сильні
code intelligenceindex бачить 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

Baseline потрібен для причинності. Без нього червоний output не можна чесно пов'язати з поточною трансформацією.


Claude спочатку знаходить contract проєкту

Запит "перевір як слід" часто породжує новий linter і окреме завдання щодо toolchain. У refactor спочатку виявляють наявні правила, нічого не встановлюючи мовчки.

Перед правкою DeliveryQuoteService:
1. Знайди verification-команди в CLAUDE.md і project scripts.
2. Розклади їх на fast, targeted і broad.
3. Для кожної команди назви ризик, який вона ловить.
4. Познач відсутні або неоднозначні checks.
5. Не встановлюй інструменти, не змінюй конфігурацію, не редагуй код.

Відповідь має бути короткою картою, а не пропозицією перебудувати toolchain:

Назви команд тут вигадані. Project-native scripts залишаються джерелом правди, а відсутність команди - finding.

Зону впливу шукають до 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
flowchart LR Q["quote"] --> C["3 виклики"] Q --> T["2 consumers типів"] Q --> U["2 файли тестів"] Q -.-> D["динамічне використання?"]

Суцільні лінії підтверджені index. Пунктир залишається uncertainty: reflection, runtime registration і plugins можуть не потрапити в symbol search.

find usages здається нудною кнопкою рівно до першого consumer, якого інакше знайшли б після PR.

Refactoring тримається на одній обіцянці

Refactoring змінює внутрішню структуру без зміни observable behavior. Розмір diff і слово refactor у назві PR нічого не класифікують.

НамірКатегоріяУ цей PR?
виділити pure helper, зберегти outputrefactorтак
виправити неправильну feebugfixокреме завдання
додати discountCodefeatureокреме завдання
оновити carrier SDKmigrationокреме завдання
переписати модуль повністю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

Деталь стає 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.

Якщо без десяти нових tests код страшно чіпати, не називайте набір lightweight. Звужуйте refactor або визнайте, що safety net потрібна глибша.

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 складається з маленьких доказів

flowchart TD A["Структурна ціль"] --> B["Baseline + safety net"] B --> C["Одна трансформація"] C --> D["Targeted sensors"] D --> E["Прочитати diff"] E --> F{"Збережено?"} F -->|так| G["Commit кроку"] F -->|ні| H["Rollback / діагностика"] F -->|неясно| I["INCONCLUSIVE / звузити"] G --> J{"Ще один крок?"} J -->|так| C J -->|ні| K["Ширші релевантні checks"]

Цикл не завершується відчуттям "стало чистіше". Кожне коло повертає рішення: commit, rollback або чесне визнання, що evidence поки недостатньо.

Цикл навмисно не виглядає героїчним. Зате rollback займає хвилини, а не решту вечора.

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.


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.

flowchart TB subgraph BEFORE [До] direction LR A1["DeliveryQuoteService"] --> B1["Відповідь Carrier SDK"] A1 --> C1["Політика quote"] A1 --> D1["Публічний DeliveryQuote"] end subgraph AFTER [Після] direction LR A2["DeliveryQuoteService"] --> C2["Політика quote"] E2["Carrier adapter"] --> B2["Відповідь Carrier SDK"] E2 --> C2 A2 --> D2["Публічний DeliveryQuote"] end

Після кроку policy більше не знає форму SDK response: adapter перетворює зовнішній формат на внутрішній input. Публічний DeliveryQuote і спостережувана поведінка залишаються незмінними.

Архітектурна віддача вимірюється не кількістю нових шарів, а кількістю причин, через які module має змінюватися.

Acceptance перевіряє безпеку й користь

Refactor PR відповідає на два запитання: обіцяна поведінка не змінилася й конкретна structural problem справді зменшилася. Друге запитання забувають частіше - велика перестановка коду легко проходить review просто тому, що нічого не зламала. Одного green suite тут замало.

CriterionEvidenceClaim limit
behavior preserved2 characterization-тести + relevant testsлише зафіксовані сценарії
public type preservedtype-check + diff public symbolsлише checked consumers
artifact still buildsnpm run buildне runtime correctness
structural payoffimport map before/afterодна boundary, не ідеальна architecture
scope controlledfile list + readable diffне виключає hidden use
rollback cheapone 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
NOT PROVEN
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
  1. знайти harness, зняти baseline і побудувати impact map;
  2. назвати спостережувану поведінку й додати 1-2 characterization-тести;
  3. зробити одну transformation, запустити sensors, прочитати diff і commit;
  4. розширити checks до claim і повернути verdict, включно з INCONCLUSIVE.

Safe refactor - не властивість відповіді Claude. Це обмежене твердження, яке можна простежити від structural goal через diff до evidence.