フォーマッターに build:cache:hit を渡すと、末尾が cache のメモになります。別の入力では出力の空白が二重になります。どちらが意図した動作かを示すテストはありません。AI に簡略化を頼む前に、この結果の変化を検知できるようにしておきます。

label:detail 形式から日付付きメモを作るフォーマッターを考えます。特性テストのサンプル一式をダウンロードし、Node.js 22 以降で実行できます。記録した実行環境は 22.22.1 で、外部依存はありません。

改善案の前に、いくつかの入力を観察する

展開して characterization-tests-before-ai-refactoring に移動し、実行します。

node observe.mjs original

入力、指定日時、指定環境、出力を含む 12 件の観察結果を JSON で表示します。普通の文字列から始め、一度に一つの要素だけ変えます。入力と出力だけを抜き出すと、次の二例が含まれます。

[
  {
    "text": "build:cache:hit",
    "output": "2026-10-13 | BUILD | cache"
  },
  {
    "text": " build : ready ",
    "output": "2026-10-13 | BUILD  |  ready"
  }
]

二つ目は最後の | の前後に空白が二つあります。調査中はこの違いを見える状態にしてください。他の結果では build と build: はどちらも - で終わり、空のラベルには指定した NOTE_LABEL、その値がないか空なら note を使います。

original.mjs を開きます。

export function formatNote(text, runtime = systemRuntime) {
  const fields = text.trim().split(':');
  const fallback = runtime.readEnv('NOTE_LABEL') || 'note';
  const label = (fields[0] || fallback).toUpperCase();
  const detail = fields[1] || '-';
  const day = runtime.now().toISOString().slice(0, 10);
  return `${day} | ${label} | ${detail}`;
}

hit が消える理由は fields[1] です。それ以降のフィールドは使いません。最初の trim() は入力全体に適用するので、build の後と ready の前の空白は残ります。整形時に区切り用の空白がさらに加わります。

観察によって、修正方法を選ぶ前に調べる対象ができます。Michael Feathers の Unit Conversations でも、既存コードを呼び、応答を調べ、役立つやり取りをテストに保存する流れを説明しています。

日付と環境を明示的な入力にする

今日の日付を含む出力を保存すると、明日は失敗します。開発者の NOTE_LABEL も空ラベルの結果を変えます。どちらもテストの入力に含める必要があります。

この例には任意引数 runtime という小さな差し替え箇所があり、既定では時計と指定されたプロセス環境変数を読みます。掲載した観察とテストでは、代わりに runtime.mjs の実装を渡します。

export function fixedRuntime({ at, env }) {
  return {
    now: () => new Date(at),
    readEnv: (name) => env[name],
  };
}

多くのケースは 2026-10-13T12:00:00.000Z と { NOTE_LABEL: 'daily' } を使います。一例では 2026-10-13T23:30:00-07:00 を使い、toISOString() が UTC で整形するため、出力は 2026-10-14 で始まります。環境値がない場合と空の場合も別に確認します。

このような差し替え箇所は seam と呼ばれます。Fowler の Legacy Seam は、テストから振る舞いを選べるよう依存を切り離す考え方を説明しています。この例には最初から引数があります。実際のコードに依存が埋め込まれているなら、パーサーの整理とは別の変更として最小限の seam を導入・レビューし、既定の値の取得方法を保ってください。

ここではグローバルな時計やプロセス環境を変更しません。子プロセスに TZ=Pacific/Kiritimati と別の NOTE_LABEL を設定しても成功し、指定した入力が結果を決めることを確認しました。既定アダプターと実際のアプリとの連携には別の結合確認が必要です。

疑問のある結果も、そのまま保存する

探索用の入力は cases.mjs にあります。元の結果を実行・確認した後、characterized.json に保存しました。意外な空白や捨てられたフィールドも含む正確な文字列を記録します。

特性テストが確認するのは、選んだ動作が変わったかどうかです。記録した動作の正しさを証明するものではありません。例えば cache を保持しても、本来 cache:hit を残すべきかは未決です。

run.mjs は保存した JSON を読み、各実装の結果と比較します。

for (const item of fixtures) {
  test(item.name, () => {
    const actual = versions[mode](item.text, fixedRuntime(item));
    assert.equal(actual, item.output);
  });
}

アサーション中に元の関数を呼んで期待値を生成することはありません。元の実装が変わっても、保存済みの答えを暗黙に再定義できません。observe.mjs も表示するだけで、fixture を上書きしません。

元の実装を記録と照合します。

node run.mjs original

12 件すべて成功します。通常入力、詳細の欠落・空値、後続フィールド、内部空白、空入力、フォールバックラベルの有無、UTC 日付を確認しています。対象は選択した文字列入力です。Unicode の大文字変換、不正な日付、文字列以外の引数、副作用の順序、実アプリの呼び出し元は対象外です。

保存した結果を保ってリファクタリングする

動作を保つ版では、解析と整形を名前付きの関数に分けます。パーサーは意図的に元のフィールド選択を残します。

function parseFields(text) {
  const [label, detail] = text.trim().split(':');
  return { label, detail };
}

function render(day, label, detail) {
  return `${day} | ${label} | ${detail}`;
}

refactored.mjs はこの関数を使い、フォールバック、大文字化、UTC 日付変換を維持します。

node run.mjs refactored

12 件すべて成功し、観察出力全体も元とバイト単位で一致します。記録した入力についての有用な裏付けであり、未テストのケースは未確認のままです。

この段階で AI を使うなら、ソースと特性テストを渡し、既存の文字列を保つこと、動作を変える案は明示することを求めます。「きれいにして」だけでは空白や区切りについて無言の判断が入りやすくなります。

魅力的に見える整理案を、見える形で失敗させる

仮の整理案 accidental.mjs は、便利そうな次のパーサーを使います。

function parseFields(text) {
  const [label, ...rest] = text
    .trim()
    .split(':')
    .map((field) => field.trim());
  return { label, detail: rest.join(':') };
}

各フィールドを trim し、残りを再結合します。三つの版を個別に実行します。

node run.mjs original
node run.mjs refactored
node run.mjs accidental
版 成功 失敗 終了コード
original 12 0 0
refactored 12 0 0
accidental 10 2 1(想定した失敗)

失敗するのは先ほどの意外な二例です。build:cache:hit は cache:hit で終わり、空白のある入力は 2026-10-13 | BUILD | ready になります。fixture の再生成で違いを隠していません。

新しい出力は node observe.mjs accidental で確認できます。想定した失敗の後も進めるならコマンドは個別に実行してください。&& でつなぐと終了コード 1 で止まります。既定の node --test run.mjs は original だけを確認します。

赤い結果は、整理案が記録済みの動作を変えたことを示します。新しい出力の方が望ましいかは、次に判断することです。

動作変更は別に一つずつ判断する

DECISION.md には、コロンが複数ある場合に cache ではなく cache:hit を残す提案があります。旧出力と提案後の出力を記録し、動作維持のリファクタリングには混ぜていません。

実アプリなら、採用前に代表的な入力とこの文字列の利用側を調べます。保存された観察だけでは、ユーザーが捨てられた部分を必要とするか、短い形式に依存するかは分かりません。採用するなら別パッチにし、理由とともに該当する期待値を更新し、新しい合意を示すケースを加えます。そのパッチでは空白と日付の規則を変えません。

ここでコード変更の影響分析が役立ちます。観察は違いを示し、呼び出し元とテストが影響を判断する助けになります。テスト付きのパッチでは実装と同時に変わったアサーションも確認し、意図した動作を守っているか調べてください。

観察結果から次に読む場所を選ぶ

整形値の出所がまだ分からなければ、不慣れなコードで値を一つ追うところから始めます。フォーマッターが見つかったら、現行実装、記録したケース、変更案を一緒に開きます。

AI を使う場合の質問例です。

各記録結果がどう作られるか説明してください。捨てるフィールド、残す空白、フォールバック、日付のコードを示してください。記録結果を保つリファクタリングを提案し、動作変更案は分けてください。保存された出力を要件と決めつけないでください。