説明には「配列も各要素も新しいので、結果を変更しても入力は変わらない」とあります。ところが、呼び出し元が label を大文字にすると、元のデータも変わります。浅いコピーが、入れ子のオブジェクトへの参照を残しているためです。

小包プレビューの実習をダウンロードしてください。コード、データ、意図的に誤りを含めた説明は、本チュートリアルのために一から作られました。Node.js 22以降を使ってください。インストール、認証情報、ネットワークアクセスは不要です。

主張をソースの隣に置く

コピー後の parcel.label を変更すると、入力も変わるのでしょうか。コピー関数と呼び出し元を並べて読みます。

展開した parcel-preview-lab フォルダーをYenpoで開きます。Command-P(Go to File)で claims.md、src/copy-parcels.mjs、src/preview.mjs をタブで開いてください。コピー処理と呼び出し元の両方に照らして主張を読みます。Search All Files で row.label.text を探せます。一致行だけで毎回入力が変更されると判断せず、周囲の uppercase 条件を調べてください。

この読解にモデル接続や言語サーバーは不要です。Mac版Yenpoを入手して同じ例を開くか、現在地が分からなくなったらファイルとテキスト検索のヘルプを参照してください。以下の観察用プログラムと確認はTerminalで自分で実行し、その証拠をメモに記録します。

説明を個々の主張に分ける

検証する教材用の説明は次のとおりです。

copyParcels は新しい配列を作り、各小包について新しいオブジェクトを作ります。ラベルも独立したコピーなので、preview で大文字にしても入力は変わりません。

最初の文には二つの主張があります。二つ目の文は所有関係の主張と、挙動についての結論を加えています。段落全体を一つの「正しいか間違いか」として扱うと、誤りとともに有用な部分まで失ってしまいます。

ID 主張 何が裏付け、または反証になるか
C1 戻り値の配列は新しい。 入力配列と出力配列の同一性を比較する。
C2 外側の小包オブジェクトはそれぞれ新しい。 対応する小包の同一性を比較する。
C3 入れ子のラベルはそれぞれ独立している。 文字列だけでなく、ラベルの同一性を比較する。
C4 プレビューを大文字化しても入力は保持される。 その分岐を通り、呼び出し後の入力を調べる。
C5 入力の変更はすべて業務上のバグである。 要件や所有関係の契約を探す。コードだけでは決まらない。

C5は読者が付け加えかねない結論であり、引用した説明には含まれません。C4を反証することで、製品要件まで暗黙に決めてしまわないよう、別に扱います。

対象範囲は、通常の変更可能な小包オブジェクトを要素とし、欠番のない配列です。各要素には通常の label オブジェクトと文字列の text があります。実習ではゲッター、プロキシー、クラスのインスタンス、外部I/Oを扱いません。実行結果をあらゆるJavaScriptの値に一般化せず、この範囲を説明の横に残してください。

関数の境界を越えて参照を追う

src/copy-parcels.mjs を開きます。

export function copyParcels(parcels) {
  return parcels.map((parcel) => ({ ...parcel }));
}

オブジェクトリテラルは、コールバックが呼ばれるたびに新しい外側のオブジェクトを作ります。コピーする label プロパティの値は、オブジェクトへの参照です。入れ子のラベルを新しく作るわけではありません。MDNのスプレッド構文と浅いコピーに、この違いが説明されています。

次に、実際の呼び出し元である src/preview.mjs を開きます。

import { copyParcels } from './copy-parcels.mjs';

export function preview(parcels, { uppercase = false } = {}) {
  const rows = copyParcels(parcels);
  if (uppercase) {
    for (const row of rows) {
      row.label.text = row.label.text.toUpperCase();
    }
  }
  return rows;
}

重要な書き込み先は row.label.text です。row.id を再代入すれば、新しい外側のオブジェクトが変わります。row.label を介して代入すると、共有された入れ子のオブジェクトに届きます。フィクスチャの場合、コピー直後の参照構造は次のとおりです。

input ──> original array ──> original parcel ──┐
                                             ├──> one label object
rows  ──> new array ──────> new parcel ────────┘

代入が見つかっただけでは、個々の実行を説明できません。既定の分岐ではこの代入を飛ばします。C4を確かめるには、uppercase: true と、大文字化すると現在の値とは異なる文字列を渡します。

説明に反する可能性のある分岐を実行する

ダウンロードしたファイルを展開し、parcel-preview-lab に移動して実行します。

node observe.mjs

観察用プログラムは、分岐ごとに新しいフィクスチャを作ります。preview を呼ぶ前に元のテキストを文字列として保存し、同一性と値を表示します。結果は次のとおりです。

観察項目 uppercase: false uppercase: true
同じ配列か false false
同じ外側の小包か false false
同じ入れ子のラベルか true true
呼び出し前に保存したテキスト fragile fragile
呼び出し後の入力テキスト fragile FRAGILE
呼び出し後のプレビューテキスト fragile FRAGILE

これはC4に対する、実際に到達できる反例です。呼び出し元が入力を渡して大文字化の分岐を選び、自分が持つ入れ子のテキストの変化を観察できます。C1とC2は引き続き正しいままです。C3は、代入しない分岐を含めて、ラベルの同一性が共有されていることからすでに反証されています。

初めから大文字のラベルは、弱い検証入力です。書き込みは起きますが、前後の値は同じになります。空の配列はループに入りません。このような入力では「テキストが変わらなかった」という確認に成功しても、独立した所有関係は確立できません。証拠を集めるテストには、両方の対照ケースが含まれています。

値が等しいことと、オブジェクトが独立していることを区別する

書き込み前には、次のアサーションが成功します。

assert.deepEqual(rows, input);

分かるのは、比較した構造と値が一致することです。対応する入れ子のオブジェクトが別々であるとは分かりません。同時に、次の三つのアサーションも成功します。

assert.notStrictEqual(rows, input);
assert.notStrictEqual(rows[0], input[0]);
assert.strictEqual(rows[0].label, input[0].label);

この実習は node:assert/strict から assert をimportします。比較の意味は、Nodeのアサーションリファレンスを参照してください。同一性は共有関係を表し、前後の値は到達可能な書き込みの結果を表します。両方の証拠を残しましょう。

「変更前」の状態の保存に const before = input を使わないでください。この変数は同じ配列を指し続けるため、変更後の値も見えてしまいます。観察用プログラムは、呼び出し前にプリミティブな文字列 beforeText を保存します。取り込んだ値と、変更可能なオブジェクトへの参照は、異なる問いに答えるものです。

結果から説明を書き直す

次のコマンドは別々に実行してください。一つ目は意図的に失敗する想定です。

node --test checks/original-claims.test.mjs
node --test checks/evidence.test.mjs

元の主張を検証する実行では、二つのテストが成功し、二つが失敗して、非ゼロで終了します。C1とC2は成功し、ラベルの独立性と入力の保持のアサーションは失敗します。証拠を集める実行では六つのテストが成功し、ゼロで終了します。これらのファイルの実行については、Nodeのテストランナーのドキュメントを参照してください。二つの実行の間でアプリケーションのソースは変更していません。アサーションが、同じプログラムについて異なる主張を表しているのです。

成功か失敗かだけではなく、判定とその限界を記録します。

ID 判定 証拠と限界
C1、C2 対象範囲で裏付けあり 配列と外側の小包は新しい。map、オブジェクトリテラル、同一性の確認が一致する。
C3 反証された 入れ子のラベルは共有される。入力と出力が同じ参照を持つ。
C4 反証された 大文字化で入力が変わる。真の分岐で fragile が FRAGILE になる。
C5 未確定 所有関係の契約が提示されていないため、要件違反とは確定できない。

修正した説明は次のようになります。

copyParcels は新しい配列と新しい外側の小包オブジェクトを作りますが、入れ子のラベルへの参照はそれぞれ維持します。uppercase: false の場合、preview はテキストを代入しません。uppercase: true の場合、共有ラベルを介して書き込みます。fragile フィクスチャでは、呼び出し元の入力が FRAGILE になります。この変更を許容するかどうかは、所有関係の契約によります。

ZIPには、この記録を含む claims.md、観察用プログラム、両方のテストファイル、ファイルのハッシュマニフェストが入っています。

証拠を読解に持ち帰る

ソースの版、確認した説明、テストの入力と出力を残します。そのうえで「二つの label は同じオブジェクトを参照し、大文字変換で元のデータも変わります。この点を踏まえて説明を書き直してください」と質問し、同じコードで回答を確認します。

YenpoのAI Code Readerでは、ローカルのソースを閲覧し、絞り込んだ質問をし、呼び出し元を追いながら参照を開けます。実習や自分の確認はTerminalまたは既存のテストツールで実行してください。Yenpoが代わりにシェルコマンドやテストを実行することはありません。

次にこの挙動を変更するか決めるなら、まず要件を確定してください。実際のパッチ案を確認する場合は、反例を使ったAI生成コードのレビューに進みます。リファクタリング前に未知の挙動を記録するには、特性テストを使ってください。