Featured image of post 公式ドキュメントを渡したのに、AIが存在しない関数を書いた。インストール済みの依存から資料を作る【最新ドキュメントを渡す】

公式ドキュメントを渡したのに、AIが存在しない関数を書いた。インストール済みの依存から資料を作る【最新ドキュメントを渡す】

公式サイトのドキュメントを貼って AI に書かせたら、入っている版に無い関数でビルドが30か所落ちた。インストール済みの依存から名前と版を取り出し、AI に渡す資料を作る方法(最新ドキュメントを渡す)を TypeScript と node --test で書きます。

第1幕: 現場 ── 書いてあるのに、無い

公式のドキュメントを渡して AI に書かせたのに、ビルドが30か所で落ちた。予約の受け付けは、明日の朝10時に始まる。

わたしは、ブドウ畑の端の農道に停めた自分の軽自動車の運転席で、ゆうべ23時41分に届いたメールを、もう一度開いていた。CI からの失敗の通知だ。CI は、プッシュするたびにビルドやテストを自動で走らせて、通ったか落ちたかを知らせてくれる仕組みのこと。件名の横に、赤いばつ印が付いている。

本文のリンクからログを開いた。ビルドの段のところに、エラーの行が縦に並んでいる。一行ごとに、ファイルの名前と位置、それからエラーの中身が書いてある。ゆうべ、布団の中で数えたときは、九つのファイルで三十個だった。

わたしは、エラーの行を指でなぞりながら、もう一度数えた。TS2305 という番号の付いた行が、何度も出てくる。番号の横に出ている名前は、同じものが繰り返されている。createShipment。quoteRates。また createShipment。三十まで数えて、指を止めた。数え直しても、三十だった。

窓の外で、朝露に濡れたブドウの葉が光っていた。ひなたワイナリーの畑は、ゆるい丘の斜面に、畝が等高線みたいに並んでいる。房は、もう黒に近い紫に色づいていた。収穫が近いのだと、素人のわたしにも分かった。

フロントガラスの向こう、畝の入り口のところで、葛城さんが誰かに紙を一枚渡しているのが見えた。葛城さんは、このワイナリーの二代目で、わたしの会社のお客さんだ。長靴に、つばの広い帽子。窓を少し下げると、声が半分だけ聞こえた。苗木を頼むのに、新しく刷ったの。区画はこれで。

葛城さんの前に立っていたのは、わたしより、たぶん年下の女の人だった。短い髪が、寝癖みたいにあちこち跳ねている。薄手の上着の左の袖から先だけが、茶色い革の大きな手袋に包まれていた。胸元で、紐に下げた小さな笛が揺れている。

女の人は、紙に目を落とさなかった。

一言、礼を言ったように見えた。それから紙を二つに、もう二つに畳んで、上着のポケットに入れた。開きもしなかった。

畝のあいだに張られた防鳥ネットの、いちばん高い支柱の上に、鳥がとまっていた。こげた木みたいな色の体に、栗色の肩。尾の先にだけ、白い帯が入っている。鷹だ、と思った。本物を見るのは初めてだった。鷹は、くちばしで胸の羽を整えていた。

女の人は、わたしの車の前を横切って、畝のあいだへ入っていった。急がずに、房を見ながら歩いていく。ときどき、房の下に指を入れて、持ち上げるようにしてから、また離した。

わたしは、ノートパソコンを閉じて車を降りた。約束は9時で、まだ8時半を少し過ぎたところだった。

「葛城さん、おはようございます。すみません、早く着いてしまって」

「おはよう、日比野さん。いいのいいの。早いほうが助かる」

葛城さんは、帽子のつばを指で上げた。わたしは、ゆうべのことを言おうとして、言えなかった。明日の朝10時に、このワイナリーの収穫体験の予約を受け付け始める。その画面を作ったのは、うちの会社で、担当はわたしだ。

「今日はね、鷹匠さんに来てもらってるの。ムクドリが毎朝来るのよ。摘むのは明後日からだから、それまで持たせたくて」

葛城さんが、畝のほうへ手を振った。女の人が振り返って、こちらへ戻ってきた。

「鷹宮さん。こちら、うちのサイトを作ってくれてる日比野さん」

「鷹宮です」

女の人は、それだけ言って、軽く頭を下げた。わたしも頭を下げた。

「葛城さん。地図、ありがとうございます。先に、歩いてきます」

鷹宮さんは、また畝のほうへ歩いていった。葛城さんは、蔵の様子を見てくると言って、坂の上の建物へ戻っていった。

わたしは、車のボンネットにノートパソコンを置いた。金属がもう、朝日でほんのり温かい。

ゆうべのことを、順番に思い出した。

わたしは、地元の小さな Web 制作会社で働いている。社員は四人で、わたしは入って二年目だ。ひなたワイナリーのサイトは、この春に先輩から引き継いだ。今年の秋の新しい企画で、収穫体験の予約をサイトで受け付けることになった。それに合わせて、直売のワインをクール便で送れるようにしてほしい、と葛城さんに頼まれていた。

発送のラベルを作る処理は、去年、先輩が組んだものだ。配送業者のはこび便が出している SDK を使っている。SDK は、その会社のサービスをプログラムから使うための部品で、名前は hakobi-sdk という。

昨日の夕方、わたしは AI に、クール便の発送の画面と処理を作ってもらった。会社の四人とも、コードの半分くらいは AI のコーディングエージェントに書いてもらっている。頼みごとを文で伝えると、自分でリポジトリのファイルを開いて、書いて、動かして、結果を返してくる道具だ。

頼むときには、はこび便の公式サイトのリファレンスのページを、丸ごと貼った。

そうするようになったのは、先月からだ。別の改修で、AI が SDK の古い書き方を使ってきたことがあった。AI は、作られたときに読んだ大量の文章をもとに書く。だから、読んだときより新しい書き方を知らないことがある。それで手戻りになってから、わたしは、AI の覚えていることより、公式のページのほうが新しいはずだと思って、頼むたびにページを貼るようにしていた。

AI は、画面と処理を作って、できましたと報告した。わたしは画面を開いて、見た目を確かめて、夜にプッシュした。23時41分に、通知が来た。

ボンネットの上で、ログを開き直して、一行に目を留めた。

1
src/shipping/cool-label.ts(3,10): error TS2305: Module '"hakobi-sdk"' has no exported member 'createShipment'.

行の頭の src/shipping/cool-label.ts(3,10) は、エラーのあったファイルと、その3行目の10文字目、という意味だ。

このサイトは TypeScript で書いている。TypeScript は、JavaScript に型を付けた言語だ。公開する前に、tsc というコンパイラで、型が合っているかを調べてから JavaScript に変換する。この工程を、うちではビルドと呼んでいる。Node.js(以下 Node)は、Node 24 なら .ts をそのまま動かせる。ただ、そのときは型を取り除いて動かすだけで、型は調べない。だから、手元で画面が動いても、ビルドで落ちることがある。

TS2305 は、そのモジュールに、その名前の export が無い、というエラーだ。ライブラリは、外から使ってほしい関数や値に名前を付けて、外に出している。これを export と言う。使う側は、import { createShipment } from 'hakobi-sdk' のように、名前で取り込む。エラーは、hakobi-sdk は createShipment という名前を外に出していない、と言っていた。

わたしは、ブラウザで、はこび便の公式サイトを開き直した。昨日貼ったのと同じ、リファレンスのページだ。

createShipment は、ちゃんとあった。

荷物を登録して、送り状を作ります。見出しの下に、そう書いてある。quoteRates も、cancelShipment もあった。

書いてある名前を、無い、と言われている。

わたしは、ボンネットに手をついて、画面とログを交互に見た。どちらも、本物の画面だった。

顔を上げると、畝のずっと奥で、鷹宮さんがしゃがんでいた。何かを指でつまんで、目の前に持ってきて見ている。ここからは、何をつまんでいるのか見えなかった。上着のポケットから、葛城さんの紙の角が、少しだけのぞいていた。

第2幕: 再現 ── 来年の図

9時少し前に、葛城さんが坂を下りてきた。

「日比野さん、鷹、見ていく? せっかくだから」

断る理由が思いつかなかった。わたしはノートパソコンを閉じて、葛城さんのあとについて畝のあいだへ入った。土が柔らかくて、スニーカーの底に湿った重さがつく。房の近くを通ると、甘い匂いがした。

鷹宮さんは、拳の上に鷹を据えて、畝の端に立っていた。鷹は、いつの間にか支柱から下りてきていた。

葛城さんが、ポケットを指さした。

「鷹宮さん、さっきの地図、見てくれた? 鳥が来るのは、たぶん B のほうだと思う。赤いのは、あっちだから」

鷹宮さんは、葛城さんのほうを見て、小さく頷いた。そして、B のほうへは行かなかった。

鷹宮さんは、畝を二本またいで、反対側へ歩いていった。斜面の下、畑の境に植えられた木の列の端まで行って、そこで止まった。風が、木の列のほうから吹いてくる。鷹宮さんは、風を背にするのではなく、風を受ける向きに立っていた。

拳が、少しだけ上がった。

鷹が、拳を蹴った。低く、木の列に沿って飛んでいく。羽の音は、思ったより静かだった。

次の瞬間、木の列が鳴った。葉の陰から、小さな鳥が一斉に飛び出した。何十羽、もっといたかもしれない。群れは、黒い煙みたいに一つにまとまって、畑の上で一度大きく曲がって、丘の向こうへ流れていった。

鷹は、群れを追わずに戻ってきて、防鳥ネットの支柱にとまった。

「あら」

葛城さんが、首をかしげた。

「C のほうだったの。でも、C は白のはずなのに。シャルドネで……」

そこまで言って、葛城さんは自分で吹き出した。

「やだ、それ来年の図だ。C のメルローを抜いて、シャルドネに植え替えるの、収穫のあとなのよ。苗木を頼むのに刷ったから、植え替えたあとの区画で描いてあるの」

わたしは、横でそれを聞いていた。

葛城さんは、蔵から電話だと呼ばれて、坂を上がっていった。畝の端に、鷹宮さんとわたしが残った。

「あの」

わたしは、自分から声をかけていた。

「すみません、さっきの地図……葛城さんが、わざわざ刷ってきたものですよね。開かなかったのは、どうしてですか」

鷹宮さんは、ポケットの上から紙を軽く押さえた。

「来年の、だから」

「でも、来年のでも、今年と同じところのほうが多いんじゃないですか。見れば、早かったのかなって」

口に出したとたん、失礼だったかもしれない、と首のあたりが熱くなった。鷹宮さんは、気にした様子もなく、近くの房を一つ、指の背で持ち上げた。黒に近い紫。粒が詰まって、重そうだった。

「今日の畑、見たいので」

「今日の……」

「どこが同じで、どこが違うか。歩くまで、分かんないです」

それだけ言うと、鷹宮さんは、房を戻して、拳の鷹を見た。

わたしは、ボンネットのパソコンに戻りながら、鷹宮さんの言ったことを考えていた。

葛城さんの刷った図は、葛城さんの手元でいちばん新しい紙だった。でも、描いてあったのは、来年の畑だった。今年の C には、まだメルローが実っている。

いちばん新しい紙が、今日の畑とは限らない。

わたしが貼ったページは、はこび便の公式サイトの、いま出ているページだった。いちばん新しいはずの紙だ。でも、それは、何の紙だったんだろう。

パソコンを開いて、サイトのリポジトリの package.json を開いた。依存しているパッケージと、その版(バージョン)が書いてあるファイルだ。

1
2
3
4
5
{
  "dependencies": {
    "hakobi-sdk": "^5.3.0"
  }
}

^5.3.0 は、5.3.0 以上で、6 にはならない範囲なら、新しいものを入れてよい、という指定だ。版の番号は、たいてい三つの数字でできていて、先頭の数字をメジャーバージョンと呼ぶ。先頭の数字が変わるときは、名前が消えたり、変わったりすることがある。だから、先頭が 5 のまま、と決めてある。

実際に入っている版は、package-lock.json に書いてある。lockfile と呼ばれるファイルで、npm install したときに入れた版を、一つずつ書き留めておくものだ。これがあれば、誰がいつ入れ直しても、同じ版が入る。開くと、hakobi-sdk は 5.3.0 だった。リポジトリの node_modules というフォルダに入っている実物も、同じ 5.3.0 だ。node_modules は、入れた依存のパッケージが、そのまま置かれている場所のことだ。

もう一度、はこび便の公式サイトのリファレンスを開いた。ページの右上に、小さな選択欄があった。今まで、気にしたこともなかった。表示は v6.0.0。サイトのお知らせを見ると、v6 は先月出ていた。

選択欄を v5.3.0 に切り替えた。

ページの中身が変わった。createShipment が消えて、そこに createLabel が出てきた。quoteRates は getRates になった。cancelShipment は cancelLabel。schedulePickup は、どこにも無かった。

6 と、5。

わたしは、ログに戻って、エラーをもう一度数えた。今度は、種類で分けた。名前が無い、という TS2305 が二十四。残りの六つは、関数に渡しているものの形が違う、返ってくるものの形が違う、というエラーだった。存在しない四つの名前が、九つのファイルの中で、何度も使われていた。

AI は、わたしが貼ったページのとおりに書いた。

AI は、自分の覚えていることよりも、会話に入れて渡されたもの(これをコンテキストと呼ぶ)に沿って書くことが多い。必ずではないけれど、多い。だから先月から、わたしはページを貼るようにした。昨日は、それが裏目に出た。

確かめたくなった。昨日の頼み方で、AI は、ページから何を受け取っていたのか。

わたしは、貼ったページから、版と名前を抜き出す関数を書いた。AI に渡す API の資料を、こういう形で表すことにした。

1
export type ApiNotes = { pkg: string; version: string; names: string[] };

pkg はパッケージの名前、version は版、names は使える名前の一覧だ。

1
2
3
4
5
6
7
8
9
// 貼ったページの見出しから、版と名前を抜く
//   # <pkg> v<version> ...
//   ### <name>(...)
export function buildNotesFromDoc(doc: string): ApiNotes {
  const head = doc.match(/^# (\S+) v(\S+)/m);
  if (!head) throw new Error('先頭の見出しに版がない');
  const names = [...doc.matchAll(/^### ([^\s(]+)/gm)].map((m) => m[1]).sort();
  return { pkg: head[1], version: head[2], names };
}

リファレンスのページは、先頭の見出しに # hakobi-sdk v6.0.0 リファレンス のように版が入っていて、関数ごとに ### createShipment(options) のような見出しが付いている。正規表現で、その二つを拾う。\S+ は空白以外の文字の続き、[^\s(]+ は空白と ( 以外の文字の続きだ。末尾の m は行ごとに ^(行の頭)を効かせる指定、g は一致するものを全部探す指定。matchAll は、一致したものを全部取り出す。[...] で、それを配列に広げている。m[1] や head[1] は、括弧で囲んだ部分に一致した文字列だ。

昨日貼ったページを渡すと、版は 6.0.0、名前は七つ出てきた。COOL_TYPES、cancelShipment、createShipment、quoteRates、schedulePickup、trackShipment、validateAddress。昨日の AI は、この七つを、使える名前として受け取っていたことになる。

この資料が、入っている版と合っているかを確かめるテストを書いた。いまの作りでは赤になるはずのものを先に書いて、本当に赤になるのを確かめておく。最初から緑になるテストだと、今朝のずれを本当に捕まえられるのか分からないからだ。入っている 5.3.0 の名前は、v5 のページと node_modules の中身を見て、テストに手で書いた一覧(installed)にした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
test('資料の名前は、入っている版にすべて実在する', () => {
  for (const name of notes.names) {
    assert.ok(installed.names.includes(name), `${name} は入っている版に無い`);
  }
});

test('入っている版の名前は、資料にすべて載っている', () => {
  for (const name of installed.names) {
    assert.ok(notes.names.includes(name), `${name} が資料に無い`);
  }
});

test('資料の版は、入っている版と同じ', () => {
  assert.equal(notes.version, installed.version);
});

notes は、貼ったページから作った資料だ。assert.ok は、中身が真でなければ失敗にする。二つ目の引数は、失敗したときに出す文だ。一本目は、資料に無い名前が混じっていないか(今朝の事故そのもの)を見る。二本目は、使える名前が資料から漏れていないか(書き忘れ)を見る。片方だけだと、もう片方のずれを見逃す。node --test にかけた。

1
2
3
✖ 資料の名前は、入っている版にすべて実在する
✖ 入っている版の名前は、資料にすべて載っている
✖ 資料の版は、入っている版と同じ

三本とも赤だった。assert は最初に失敗したところでそのテストを打ち切るので、出てくるのは最初の一件だけだ。一本目は cancelShipment は入っている版に無い で止まり、二本目は cancelLabel が資料に無い で止まった。三本目は、6.0.0 と 5.3.0 が違う、と言っていた。

貼ったページは、公式で、いちばん新しかった。でも、入っている版と、一度も比べていなかった。

わたしは、パソコンの画面を見たまま、直し方を思いついた。ページが v6 なら、入っている SDK を v6 に上げればいい。そうすれば、ページと合う。AI が書いたものも、そのまま——

「日比野さーん。コンテナ、数えるの手伝ってくれる?」

坂の上から、葛城さんの声がした。わたしは、思いついたことを口に出す前に、パソコンを閉じた。

第3幕: 手順を整える ── 実物から作る

10時。わたしは、C 区画の畝の端で、摘み取りに使うプラスチックのコンテナを数えていた。明後日の朝、これにブドウを摘んで入れる。黄色いコンテナが、畝の端に何列も積んである。三十二、三十三、と数えていると、気持ちが少し落ち着いた。

鷹宮さんが、畝の奥から戻ってきた。拳には何も載っていない。鷹は、支柱の上だった。

わたしは、数えかけの列から顔を上げた。

「鷹宮さん。さっきの、すみません、もう一つ聞いてもいいですか。地図を開かないで、どうして鳥のいるところが分かったんですか」

鷹宮さんは、少し考えるように、畝の先を見た。

「図は、描いた日の畑しか、知らないので」

「描いた日の」

「葛城さんが描いた日。あれは、来年の」

それから、鷹宮さんは、自分の歩いてきた順番を、短く言った。下から、風の来るほうから歩いた。色の付いた房を見た。つつかれた粒が、C に多かった。木の列の葉の陰に、鳥がたまっていた。だから、そこから放した。

言い終わると、鷹宮さんは口を閉じた。説明は、それで全部らしかった。

わたしは、コンテナの縁に手を置いたまま、それを自分の仕事に置き換えていった。

図には、描いた日の畑しか載っていない。今日の畑と合っているかどうかは、図のどこにも書いていない。リファレンスのページも同じだ。わたしのサイトに入っている版と合っているかどうかは、ページの側からは、分からない。

それなら。

「あの、さっき思いついたんですけど。入っている SDK を、v6 に上げればいいんですよね。ページは公式で、いちばん新しい。上げれば、ページと合うから、AI が書いたものは、そのまま通る」

しゃべっているうちに、これで決まりのような気がしてきた。ページと、入っている版を合わせる。合わなかったのが原因なのだから、合わせればいい。

返事は、言葉では来なかった。

かわりに、鷹宮さんはしゃがんで、すぐそばの房を一つ、根元のところから指で持ち上げた。房を少し回して、わたしの目の高さに向ける。粒がぎっしり詰まって、手のひらからこぼれそうなくらい重そうだった。黒に近い紫の皮に、白い粉がうっすらと吹いている。明後日には摘まれる房だ。

「今年の房」

鷹宮さんは、それだけ言った。

ちょうどそのとき、葛城さんが、摘み取りの段取りを書いたボードを抱えて、畝の端を通りかかった。

「明後日の朝から、C を摘むからね。7時集合。日比野さん、予約のほうはお願いね」

「は、はい」

「植え替えは、摘み終わってから。今の木を抜くのは、そのあと」

葛城さんは、わたしたちの話は聞いていなかった。段取りを伝えて、そのまま次の区画へ歩いていった。

摘み終わってから、抜く。

わたしは、その言葉に引っかかった。ポケットからスマホを出して、はこび便の公式サイトを開いた。v6 のお知らせの下に、移行ガイドへのリンクがあった。v5 から v6 へ上げるときに、何が変わるかをまとめたページだ。昨日は、開いてもいなかった。

変更の一覧の、いちばん上に、削除された関数が並んでいた。createLabel。getRates。cancelLabel。

先輩が去年組んだ、発送のラベルの処理を思い出した。いま、ちゃんと動いている処理だ。ワインの注文が入るたびに、毎日ラベルを作っている。あの処理は、createLabel と getRates を使っている。

上げれば、いま動いているほうが落ちる。今夜のうちに。

どれだけの数のファイルが、その二つを使っているのか、数えようとして、やめた。数えなくても、落ちることは分かった。数えている時間は無い。

目の前の房を見た。図に合わせて畑を変えるなら、摘む前の木を抜くことになる。

「……合わせる向きが、逆でした」

わたしは、自分に言い聞かせるように言った。

「ページに合わせて、入っているほうを変えるんじゃなくて。入っている実物に合わせて、AI に渡すほうを作る。実物から、作る」

鷹宮さんは、持ち上げていた房を、そっと元に戻した。それから立ち上がって、次の畝のほうへ歩いていった。頷きはしなかった。でも、止めもしなかった。

わたしは、コンテナを数え終えてから、蔵の前のベンチへ移った。蔵の扉が開いていて、中から冷えた空気と、木の樽の匂いが流れてくる。日陰で、パソコンの画面がよく見えた。

調べると、今朝の困りごとには、名前の付いた手当てがあった。最新ドキュメントを渡す、という。AI に、記憶や推測で書かせない。いま手元で動いている実物から取った情報、つまりドキュメントや API の一覧を、コンテキストに入れて渡す。

ここでいう最新は、世の中でいちばん新しい版のことではない。手元で動いている版にとっての、いまの実物どおりの情報のことだ。昨日のわたしは、その言葉の「渡す」だけをやっていた。

渡し方は、いくつかある。ページを貼る。URL を渡して、AI に読みに行かせる。MCP を使う。MCP は、AI のツールに、外のデータや道具をつなぐための、決まった口のことだ。この口につないで、ライブラリのドキュメントを返してくれるサーバーもある。

その一つに、Context7 というものがある。説明のページ(README)には、こう書いてあった。

To get documentation for a specific library version, just mention the version in your prompt:

特定の版のドキュメントがほしいときは、頼む文の中に、その版を書けばいい。版を選ぶことはできる。ただ、プロジェクトの lockfile を読んで、入っている版に自動で合わせる、という記述は見つからなかった。版を書くのは、頼む人だ。

ページを貼るのでも、URL を渡すのでも、MCP でも、渡すのは、誰かが書いた資料だ。どの版の資料かを、わたしが選び間違えれば、また今朝と同じことになる。

そこまで考えて、わたしは手を止めた。

それなら、サイトの選択欄を v5 に切り替えて、ページを貼り直せば済むのではないか。

たぶん、今夜は、それで通る。それは認める。でも、と思った。昨日わたしが滑ったのは、まさに、版を選ぶところだった。それに、そのページが、入っている版の名前を全部載せているかどうかは、ページを見ても分からない。書き忘れがあっても、ページの側からは気づけない。来年、SDK を上げたら、同じ選択が、また来る。

だから、入っている実物から作る。版を選ぶ手順そのものを、無くす。引き継ぎのメモのように、人が書いて渡すものではない。材料は、node_modules に入っている依存そのものだ。人は書かない。機械が、実物から取ってくる。

まず、依存を読み込むところを書いた。

1
2
3
4
5
// 依存の名前空間を読む(import() は自分の位置から探すので、dir の node_modules を指す絶対パスを先に得る)
export async function loadNamespace(name: string, dir: string): Promise<Record<string, unknown>> {
  const abs = createRequire(join(dir, 'package.json')).resolve(name);
  return await import(pathToFileURL(abs).href);
}

await import() は、プログラムを動かしている途中で、モジュールを読み込む書き方だ。読み込むと、そのモジュールが外に出している名前が全部入ったオブジェクトが返ってくる。これを名前空間と呼ぶ。名前空間の中では、export の名前がそのままキーになっている。

ただ、import('hakobi-sdk') と名前だけ渡すと、Node は、この関数を書いたファイルの位置から node_modules を探しに行く。テストでは、一時的なディレクトリに置いた見本のパッケージから読みたい。ほかのプロジェクトの依存も読めるようにしておきたい。だから、どこの node_modules を読むかを、dir で外から渡せるようにした。そこで、createRequire を使う。指定したディレクトリの package.json を起点にして、パッケージの探し方を作る関数だ。その resolve で、dir の node_modules にある hakobi-sdk の、本体のファイルの絶対パスを得る。createRequire・join・pathToFileURL、あとで使う readFileSync は、どれも Node に最初から入っている関数だ。戻り値の型 Promise<Record<string, unknown>> は、文字列のキーに何かの値が入ったオブジェクトを、あとで渡すという約束、くらいに読めばいい。pathToFileURL で、そのパスを import() が読める URL の形に直してから、読み込む。

名前空間のキーを、そのまま並べる関数も書いた。

1
2
3
4
// 素朴版: 名前空間のキーをそのまま並べる
export function namesNaive(ns: object): string[] {
  return Object.keys(ns).sort();
}

Object.keys は、オブジェクトのキーを配列で返す。sort は、並べ替える。並びを毎回同じにしておきたかった。

入っている hakobi-sdk で走らせた。

1
COOL_TYPES, cancelLabel, createLabel, default, getRates, module.exports, trackShipment, validateAddress

createLabel がある。getRates も、cancelLabel もある。createShipment は無い。

ほっとしかけて、二つの名前に目が止まった。default。module.exports。

はこび便の SDK に、そんな関数は無い。v5 のページにも、v6 のページにも無い。

わたしが AI に渡そうとしていた資料に、無い名前が入りかけていた。

思わず、小さく笑ってしまった。今朝から、無い名前を書かれて困っていたのに、自分の作った資料が、同じことをしようとしていた。これをそのまま渡したら、AI が default という関数を呼ぶコードを書いても、文句は言えない。

調べると、理由はモジュールの書き方にあった。Node のモジュールには、古くからある CommonJS(CJS)と、今の標準の ES Modules(ESM)の二つの書き方がある。はこび便の SDK は、CJS で書かれている。CJS のモジュールを import() で読むと、名前空間に、default と module.exports という二つのキーが足される。どちらも、モジュール全体を指すための入り口で、SDK が外に出している関数の名前ではない。CJS の名前のほうは、Node がファイルを実行せずに、exports.名前 = のような字面から拾って並べている。実行してみないと決まらない書き方をしていると、名前が出てこないこともある。

テストを書いた。

1
2
3
4
5
test('default と module.exports は、名前に入れない', async () => {
  const names = namesNaive(await loadNamespace('hakobi-sdk', dir));
  assert.ok(!names.includes('default'));
  assert.ok(!names.includes('module.exports'));
});

! は、真と偽をひっくり返す。名前に default が含まれていなければ通る。素朴な関数では、これは赤だった。二つを除くように直した。

1
2
3
4
5
6
// 素朴版から、default と 'module.exports' を除いた版(default の中身はまだ足さない)
export function namesExclude(ns: object): string[] {
  return Object.keys(ns)
    .filter((k) => k !== 'default' && k !== 'module.exports')
    .sort();
}

filter は、条件に合うものだけを残す。これで、hakobi-sdk の名前は六つになった。

そこで、もう一つの依存のことを思い出した。明日の予約の画面で、空いている枠を出しているのは、kakeru-calendar という予約カレンダーの部品だ。明日いちばん使われるのは、たぶん、こっちだ。こっちの資料も作っておこうと思った。

走らせると、名前が一つも出てこなかった。

空の配列だった。openSlots も、reserve も無い。

kakeru-calendar の中を開いた。ESM で書かれていて、最後の一行が、こうなっていた。

1
export default { openSlots, reserve, release };

export default は、名前を付けずに、一つだけ外に出す書き方だ。この部品は、三つの関数を一つのオブジェクトにまとめて、それだけを出している。だから、名前空間のキーは default だけになる。わたしは、さっきその default を除いた。除いたら、何も残らなかった。

default は捨てるだけでなく、中を見る必要があった。テストを足した。

1
2
3
4
test('default だけの依存でも、中身の名前が入る', async () => {
  const names = namesExclude(await loadNamespace('kakeru-calendar', dir));
  assert.deepEqual(names, ['openSlots', 'release', 'reserve']);
});

assert.deepEqual は、配列の中身まで同じかを確かめる。二つを除いただけの関数では、これが赤になる。default の中身のキーを足すように直して、名前を取り出す関数を仕上げた。

1
2
3
4
5
6
export function namesOf(ns: Record<string, unknown>): string[] {
  const names = Object.keys(ns).filter((k) => k !== 'default' && k !== 'module.exports');
  const d = ns.default;
  if ((typeof d === 'object' && d !== null) || typeof d === 'function') names.push(...Object.keys(d));
  return [...new Set(names)].sort();
}

typeof は、値の種類を文字列で返す。default がオブジェクトか関数なら、その中のキーを足す。null も typeof では 'object' になるので、別に除いている。CJS では、モジュール全体が一つの関数になっていることもあるので、関数も見ている。push(...配列) は、配列の中身を一つずつ足す書き方だ。CJS の SDK は、default の中にも同じ名前が入っているので、名前が二回ずつ出てくる。new Set は、重複を除いた集まりを作る。それを [...] で配列に戻して、並べる。

二つの依存で、名前空間のキーがどう残っていくかを、並べて書いておいた。

二つの依存で名前空間のキーが整理される流れ。hakobi-sdk(CJS)はキー8つから default と module.exports を除き、default の中身を足して重なりを除くと名前は6つ。kakeru-calendar(ESM)はキーが default だけで、除くと何も残らず、default の中身を足すと openSlots・release・reserve の3つになる

どちらの依存でも、最後に残ったのは、入っている版が外に出している名前だけだった。

次は、版だ。入っている依存の package.json の version を読めばいい。最初は、さっきと同じ resolve で hakobi-sdk/package.json を探そうとした。すると、ERR_PACKAGE_PATH_NOT_EXPORTED というエラーが出た。パッケージの package.json には、外から読んでいいファイルを決める exports という欄を書ける。はこび便の SDK は、そこに本体のファイルしか書いていない。だから、package.json は外から探せない。

それなら、置き場所が分かっているのだから、直接読めばいい。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// dir の node_modules に入っている依存そのものから、名前と版を取り出す
// ponytail: dir 直下の node_modules だけ読む(pnpm の非 hoist 構成や親ディレクトリの node_modules は読めない)。そういう構成なら resolve の結果から package.json を探しに上る
export async function buildNotesFromPackage(name: string, dir: string): Promise<ApiNotes> {
  // import() は自分のファイルの位置から探すので、dir の node_modules を指す絶対パスを先に得る
  const abs = createRequire(join(dir, 'package.json')).resolve(name);
  const ns = await import(pathToFileURL(abs).href);
  // resolve(name + '/package.json') は exports を持つ依存で ERR_PACKAGE_PATH_NOT_EXPORTED になるので、直接読む
  const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', name, 'package.json'), 'utf8'));
  return { pkg: name, version: pkg.version, names: namesOf(ns) };
}

readFileSync でファイルを文字列として読み、JSON.parse でオブジェクトに直して、version を取る。ponytail: で始まるコメントは、わざと手を抜いたところの印だ。この関数は dir のすぐ下の node_modules しか見ない。依存を別の場所に置く道具を使っている構成や、親のディレクトリの node_modules に入っている依存は読めない。うちのサイトは、依存がすぐ下にあるので、これで足りる。

貼ったページの資料で赤だった三本を、今度はこの関数で作った資料に向けて走らせた。三本とも緑になった。

最後に、もう一つ確かめておきたいことがあった。

v5 のページを貼っていたら、どうだったか。

1
2
3
4
5
6
7
8
test('資料と入っている版が合っていれば、貼った資料から作ったものと同じになる', async () => {
  assert.deepEqual(await buildNotesFromPackage('hakobi-sdk', dir), buildNotesFromDoc(DOC_V5));
  assert.deepEqual(await buildNotesFromPackage('kakeru-calendar', dir), buildNotesFromDoc(DOC_CALENDAR));
  assert.equal(
    renderNotes(await buildNotesFromPackage('hakobi-sdk', dir)),
    renderNotes(buildNotesFromDoc(DOC_V5)),
  );
});

renderNotes は、資料を AI に渡す Markdown に書き出す関数で、中身はあとで見せる。書き出した文字列まで同じになるかも、ついでに確かめている。DOC_V5 は、選択欄を v5.3.0 に切り替えたときのページ、DOC_CALENDAR は予約カレンダーの部品のページだ。どちらも、入っている版のページで、入っている版の名前を全部載せている。そういうページから作った資料と、実物から作った資料は、名前も版も同じになるはずだ。これも緑だった。

ページが入っている版のものなら、貼っても同じだった。違いが出るのは、合っていないときだけだ。実物から作るやり方は、合っているときの答えを変えない。

昨日のわたしは、AI の覚えていることより、公式のページのほうが新しいと思って貼った。それは、半分は合っていた。足りなかったのは、ページの版を、入っている実物と合わせることだった。合わせる向きは、実物から。

第4幕: 検める ── また歩く

昼すぎ、蔵の前のベンチで、資料を作るためのスクリプトを書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import { buildNotesFromPackage } from './after.ts';
import { renderNotes } from './notes.ts';

// 使い方: node api-notes.ts <依存の名前>  (カレントディレクトリの node_modules から資料を作って標準出力へ)
const name = process.argv[2];
if (!name) {
  console.error('依存の名前を指定してください');
  process.exit(1);
}
process.stdout.write(renderNotes(await buildNotesFromPackage(name, process.cwd())));

ここまでの関数は after.ts に、資料の型と書き出しは notes.ts にまとめてある。process.argv[2] は、コマンドに付けて渡した一つ目の値だ。process.cwd() は、いまいるディレクトリ。process.exit(1) は、失敗したことを呼び出し元に知らせる終わり方だ。ESM のファイルなら、いちばん外側でも await を書ける。資料を Markdown にするのは、この関数にした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 資料を Markdown にする。引数と型は載せない(名前と版だけ)
export function renderNotes(notes: ApiNotes): string {
  const lines = [
    `# ${notes.pkg} ${notes.version} の API 資料`,
    '',
    ...notes.names.map((n) => `- ${n}`),
    '',
    `引数と型はこの資料に無い。node_modules/${notes.pkg}/index.d.ts を見ること。`,
    '',
  ];
  return lines.join('\n');
}

最後の一行は、書いているうちに足した。この資料には、名前と版しか入らない。関数に何を渡して、何が返ってくるかは、入らない。それを、資料を読む側に、正直に伝えておきたかった。index.d.ts は、パッケージに入っている型の定義のファイルだ。引数や戻り値の形は、そこに書いてある。

package.json の scripts に、一行足した。npm run で呼べるコマンドに名前を付けておく欄だ。

1
2
3
4
5
{
  "scripts": {
    "api-notes": "node tools/api-notes.ts hakobi-sdk > API-NOTES.md"
  }
}

npm run api-notes を走らせると、API-NOTES.md ができた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# hakobi-sdk 5.3.0 の API 資料

- COOL_TYPES
- cancelLabel
- createLabel
- getRates
- trackShipment
- validateAddress

引数と型はこの資料に無い。node_modules/hakobi-sdk/index.d.ts を見ること。

貼ったページに比べると、拍子抜けするほど短い。でも、ここに書いてある名前は、全部、入っている版に実在する。入っている版の名前は、全部ここにある。版は 5.3.0 だ。

テストを全部走らせた。

1
2
3
4
5
6
7
✔ 資料の名前は、入っている版にすべて実在する
✔ 入っている版の名前は、資料にすべて載っている
✔ 資料の版は、入っている版と同じ
✔ default と module.exports は、名前に入れない
✔ default だけの依存でも、中身の名前が入る
✔ 資料と入っている版が合っていれば、貼った資料から作ったものと同じになる
✔ api-notes.ts の出力は、本文に載せた API-NOTES.md と同じ

全部、緑だった。最後の一本は、スクリプトを本当に別のプロセスとして走らせて、出てきた文字列が、上の API-NOTES.md と一字も違わないことを確かめている。テストの中では、本物の node_modules の代わりに、一時的なディレクトリに hakobi-sdk と kakeru-calendar の見本のパッケージを書き出して、そこから読んでいる。見本の形は、本物の SDK と同じ CJS と、カレンダーと同じ ESM に合わせてある。

この資料を、AI にどう渡すか。ここからは、2026年10月22日時点の Claude Code の書き方だ。Claude Code の公式の手引きには、ファイルの渡し方について、こう書いてある。

Reference files with @ instead of describing where code lives. Claude reads the file before responding.

コードの場所を言葉で説明するより、@ を付けてファイルを指せ。Claude は、答える前にそのファイルを読む。わたしは、頼む文に @API-NOTES.md を付けることにした。

1
@API-NOTES.md を見て、発送の処理を、入っている版で書き直してください。

指示書のファイル(CLAUDE.md)の中に @API-NOTES.md と書いて、取り込ませる手もある。ただ、公式の説明によれば、指示書に取り込んだファイルは、起動のたびに全文が読み込まれる。毎回コンテキストを使うし、作った日の資料が、黙って毎回渡されることになる。頼むときに、頼む文で渡す。

MCP で渡すなら、プロジェクトで共有するサーバーは、リポジトリのいちばん上の .mcp.json に登録する。claude mcp add に --scope project を付けて追加すると、ここに書かれる。ドキュメントを返すサーバーを使うなら、頼む文に版を書く。書き忘れれば、サーバーは、たぶん新しいほうを返す。わたしの資料は、入っている版から機械が作るので、版を書き忘れることが無い。

AI に、@API-NOTES.md を付けて、発送の処理を入っている版で書き直すよう頼んだ。AI は、createShipment を createLabel に、quoteRates を getRates に置き換えて、schedulePickup を使っていたところは、集荷の日時を画面で選ばせるのをやめる形に直した。ここに書いたのは、今日のわたしの頼みに対して返ってきたものだ。同じ資料を付けて頼み直しても、毎回この書き方になるとは限らない。

ビルドを走らせた。

TS2305 は、一つも出なかった。存在しない名前のエラーは、全部消えた。

エラーは、三つ残っていた。三つとも、createLabel に渡しているオブジェクトの、項目の名前が違う、というものだった。v6 の書き方の項目名が、そのまま残っていた。

資料には、名前しか無い。どんな形のものを渡せばいいかは、資料の最後の一行が指している index.d.ts に書いてある。わたしは、AI に index.d.ts を読ませて、三つを直してもらった。もう一度ビルドを走らせると、今度は通った。16時40分だった。

ビルドが通るまでの流れを、昨日の夜の流れと並べて、ノートに書き出してみた。

昨日の夜と今日の資料の作られ方の対比。昨日は公式サイトの先頭の v6.0.0 のページを、入っている 5.3.0 と比べずに貼り、AI が v6 の名前で書いて tsc で30エラー。今日は npm run api-notes で node_modules の hakobi-sdk 5.3.0 から名前と版を読んで API-NOTES.md を作り、@ で渡す。名前のエラーは0になり、残った形のエラー3つは AI が index.d.ts を読んで直してビルドが通る。資料の中身は node –test の7本で検める

決まった手順で作れるのは、資料ができるまでだ。AI が書くところと、それを確かめるビルドは、昨日と同じく別にある。

坂の上の蔵へ行って、葛城さんに伝えた。18時までに、公開の作業を終えられます。明日の10時、予約、始まります。葛城さんは、帽子を脱いで、よかった、と言った。

日の入りの少し前、葛城さんと二人で、畑の端に出た。

空が、橙色から薄い紫に変わりかけていた。丘の向こうから、小さな黒い点が、ぱらぱらと戻ってきた。朝ほどではないけれど、群れだった。何羽かが、C の木の列の葉の陰に降りた。

鷹宮さんは、拳を上げる前に、畝を一本、下から上まで歩いて、木の列の端で立ち止まった。それから、拳を上げた。

鷹が飛ぶと、群れはまた上がった。でも、全部は丘を越えなかった。十羽ほどが、隣の畑の電線にとまって、こちらを見ていた。

「明日の朝も、来ます」

戻ってきた鷹を拳で受けながら、鷹宮さんは葛城さんに言った。葛城さんは、助かる、と言って笑った。

わたしは、電線の鳥を見ていた。

テストで確かめたのは、資料の中身までだ。資料に載る名前は、入っている版に実在するものだけで、入っている版の名前は全部載る。版も、入っている版のものになる。ページが入っている版のものなら、ページから作った資料と同じ中身になる。

AI がそれで間違えなくなる、とは言えない。ビルドに残った三つが、その一つだ。資料が伝えるのは名前と版だけで、引数も、型も、使い方も伝えない。そこは、index.d.ts と、tsc のビルドで確かめるしかない。AI は、資料に無い名前を、推し量って書いてくることもありうる。資料は AI に渡す入力で、AI の出したものを弾く仕掛けではない。

@API-NOTES.md を付け忘れれば、何も変わらない。読むのは dir のすぐ下の node_modules の依存だけで、CJS の名前は Node が静的に見つけられたものまでしか取れない。

もう一つ。七本の緑が言っているのは、わたしが手で作った見本のパッケージを、わたしの関数がどう読むか、だけだ。Node のモジュールの読み方や、Claude Code の @ の扱いが改訂で変われば、この仕掛けは、黙って効かなくなりうる。今日の緑は、今日の Node と今日の Claude Code での緑だ。

畝の上のほうから、鷹宮さんが下りてきた。拳の上で、鷹が首を小さく回した。

「鷹宮さん。もう一つだけ、すみません」

わたしは、聞いておきたいことがあった。

「来年、SDK を上げたら……今日作った資料は、どうなるんでしょう」

鷹宮さんは、少しだけ首をかしげた。それから、C 区画のほうを見た。来年の春には、メルローの木が抜かれて、若いシャルドネの苗が植わっているはずの区画だ。

「来年は、来年の C を、また歩きます」

それだけだった。

わたしは、それを自分の言葉で受けた。来年 SDK を上げたら、上げた日に、上げた版から、同じ作り方で、また作る。作るのは、わたしではない。npm run api-notes が、入っている実物から作る。わたしがやるのは、上げたあとに、その一行を走らせることだけだ。走らせるのを忘れたら、資料は今日の版のまま残る。そこは、仕掛けでは防げない。

「あの……今日のこと、何か書いてもらえませんか。すみません。春に、この仕事を先輩から引き継いだので。次に誰かに引き継ぐとき、見せたくて」

鷹宮さんは、少し黙った。それから、葛城さんに、何か書くものはありますか、と聞いた。葛城さんは、直売所の机から、ワインの箱に付ける荷札を一枚持ってきた。紐の付いた、厚紙の札だ。

鷹宮さんは、荷札を防鳥ネットの支柱に当てて、ボールペンで書いた。そのあいだ、鷹は、支柱の上でじっとしていた。四行、書いたように見えた。書き終えると、鷹宮さんは、荷札をわたしに渡した。

「どうぞ」

それから、拳に鷹を据えて、畝の奥へ歩いていった。

わたしは、車に戻った。後ろのハッチバックを上げると、荷室の灯りが点いた。ワインの箱が二つ積んであった。明日の予約の、最初のお客さんに送る分だ。クール便で、と葛城さんに頼まれている。

灯りの下で、荷札を読んだ。四行の下が、一行ぶん空いていた。

バッグの底からペンを探し出して、荷室の床に荷札を置き、その一行を埋めた。

放鷹カード

  • 合図: 最新ドキュメントを渡す。入っている依存から、名前と版を取り出して資料を作る。頼むときに、その資料を付ける
  • 検め方: 資料の版が、入っている版と同じか。資料の名前が、入っている版に実在するか。引数と型は、index.d.ts と tsc で
  • 止めないもの: ページが入っている版のものなら、ページから作ったのと同じ資料になる。入っている版の名前は、一つも削らない
  • 鷹のせいじゃなかった: 公式のページを、入っている版と比べずに渡した。ページに合わせて、入っているほうを上げようとした
  • 自分の言葉で: AI に渡す資料は、ページからではなく、node_modules から作る。伝わるのは名前まで。形は index.d.ts とビルドで見る
comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource へ
Hugo で構築されています。
テーマ Stack は Jimmy によって設計されています。