Claude Media
Claude Codeでソースマップからスタックトレースを元のコードに戻す

Claude Codeでソースマップからスタックトレースを元のコードに戻す

minify済みのスタックトレースは行:列のままでは原因を追えません。ソースマップで元のファイルと行に戻してからClaude Codeへ渡す手順と、mapを本番に出さない扱いをまとめます。

本番で出たJavaScriptのエラーは、minify済みのスタックトレースだと app.js:1:33 のような行と列しか残りません。この数字をそのままClaude Codeに貼っても、手がかりになるのは1行に詰め込まれた圧縮コードだけです。先にソースマップで元のファイル名・行・関数名へ戻してから渡すと、Claude Codeはリポジトリの該当箇所を直接開けます。

本記事では、esbuildでminifyした小さなアプリを使い、スタックを戻す手順を順に確かめます。戻したスタックを claude -p に渡す方法と、ソースマップを本番サーバーに置かずに済ませる出力設定、Claude Code側の権限の絞り方まで扱います。

なぜ行:列のままでは原因箇所に届かないのか

ソースマップは、生成後のコードの行・列を、元のコードの行・列へ対応づけるJSONファイルです。ECMA-426の仕様は、バージョンを3に固定した上で、sources(元ファイルの一覧)、names(シンボル名)、mappings(対応づけの本体)などのフィールドを定めています。目的には、ソースレベルのデバッグと、サーバー側でのスタックトレースの難読化解除が挙げられています。

minifyされたコードは改行がほぼ消え、変数名も1文字になります。次のスタックは、その状態で実際に出たエラーです。

TypeError: Cannot read properties of null (reading 'price')
    at /<proj>/dist/app.js:1:33
    at Array.map (<anonymous>)
    at t (/<proj>/dist/app.js:1:24)
    at Object.<anonymous> (/<proj>/dist/app.js:1:118)

関数名は t で、場所は1行目の何列目かです。Claude Codeにこれを見せても、t が何の関数かは圧縮済みのバンドルを読んで推測するしかありません。元のコードに戻せば、読むべきファイルが一意に決まります。

壊れたスタックをソースマップで戻す

検証には、2ファイルだけの小さなアプリを使いました。order.items に null が混ざっていて、合計金額の計算で落ちます。

// src/cart.js
export function totalPrice(items) {
  return items.map((item) => item.price * item.qty).reduce((a, b) => a + b, 0);
}
// src/main.js
import { totalPrice } from "./cart.js";
const order = { items: [{ price: 100, qty: 2 }, null] };
console.log(totalPrice(order.items));

esbuild 0.25.0で、minifyと外部ソースマップ付きでバンドルします。確認時のNodeはv20.18.0です。

npx esbuild@0.25.0 src/main.js --bundle --minify \
  --sourcemap=external --platform=node --outfile=dist/app.js

出力は dist/app.js(130バイト)と dist/app.js.map(686バイト)でした。mapの中身は次の構造です(mappings は長いので省略します)。

{
  "version": 3,
  "sources": ["../src/cart.js", "../src/main.js"],
  "sourcesContent": ["export function totalPrice(items) {…", "import { totalPrice } …"],
  "mappings": "AAAO,SAASA,EAAWC,…",
  "names": ["totalPrice", "items", "item", "a", "b", "order", "totalPrice"]
}

Nodeの組み込みで1フレームずつ引く

node:module の SourceMap クラスには findEntry(lineOffset, columnOffset) があります。Node.jsのドキュメントでは、引数も戻り値も0始まりの値で、エラーメッセージやCallSiteに出る1始まりの行・列とは違うと明記されています。ここが最初の落とし穴です。スタックの 1:33 を引くときは、0:32 を渡します。

// resolve.mjs — 使い方: node resolve.mjs dist/app.js.map 1 33
import { SourceMap } from "node:module";
import { readFileSync } from "node:fs";
 
const [, , mapPath, line, col] = process.argv;
const sm = new SourceMap(JSON.parse(readFileSync(mapPath, "utf8")));
const e = sm.findEntry(Number(line) - 1, Number(col) - 1);
console.log(`${e.originalSource}:${e.originalLine + 1}:${e.originalColumn + 1}`);

先ほどのスタックの3フレームを引いた結果です。

圧縮後の位置戻した位置該当する元コード
app.js:1:33戻した位置../src/cart.js:2:35該当する元コードitem.price の読み取り
app.js:1:24戻した位置../src/cart.js:2:16該当する元コードitems.map(…) の呼び出し
app.js:1:118戻した位置../src/main.js:3:13該当する元コードtotalPrice(order.items) の呼び出し

0始まりの値をそのまま使って表示すると、cart.js:1:34 のように1行ずれたまま報告してしまいます。Claude Codeに渡す前に、1を足す処理を必ず入れます。

--enable-source-maps が効く条件

Node 12.12.0以降の --enable-source-maps は、スタックトレース自体を元の位置に書き換えます。ただし、上のビルド(--sourcemap=external)では書き換えが起きませんでした。external は .map を出力しても、JSの末尾に //# sourceMappingURL= のコメントを付けないモードで、Nodeがmapの場所を知る手段がないためです。esbuildのドキュメントでも、linked はコメントを付け、external は付けないと区別されています。

--sourcemap=linked で出し直すと、node --enable-source-maps dist2/app.js のスタックは次のように変わりました。

TypeError: Cannot read properties of null (reading 'price')
    at item (/<proj>/src/cart.js:2:35)
    at Array.map (<anonymous>)
    at totalPrice (/<proj>/src/cart.js:2:16)

この方法が使えるのはNodeで動くサーバー側だけです。ブラウザで出たエラーは、ユーザーの環境から送られてくる文字列としてのスタックしか手元に無いので、前節のように手元でmapを引く必要があります。ブラウザの開発者ツールがスタックを書き換えるのは画面表示だけで、error.stack の中身は変わらないとesbuildのドキュメントにあります。

スタックを書き換えてClaude Codeに渡す

フレームを1つずつ引くのは手間なので、スタック全体を標準入力から読み、<ファイル>.js:行:列 の部分を置き換えるスクリプトにします。

// resolve-stack.mjs — 使い方: node resolve-stack.mjs dist < stack.txt
import { SourceMap } from "node:module";
import { readFileSync, existsSync } from "node:fs";
import { basename, join } from "node:path";
 
const distDir = process.argv[2] ?? "dist";
const cache = new Map();
const load = (name) => {
  if (!cache.has(name)) {
    const p = join(distDir, `${name}.map`);
    cache.set(name, existsSync(p)
      ? new SourceMap(JSON.parse(readFileSync(p, "utf8"))) : null);
  }
  return cache.get(name);
};
 
const frame = /([^\s(]+?\.js):(\d+):(\d+)/g;
const out = readFileSync(0, "utf8").replace(frame, (whole, file, l, c) => {
  const sm = load(basename(file));
  const e = sm?.findEntry(Number(l) - 1, Number(c) - 1);
  if (!e || e.originalSource === undefined) return whole;
  return `${e.originalSource}:${e.originalLine + 1}:${e.originalColumn + 1}`
    + (e.name ? ` [${e.name}]` : "");
});
process.stdout.write(out);

mapが見つからないフレームや、node:internal のようにmap対象外のフレームは、元の文字列のまま残します。先ほどのスタックに通すと次のようになりました。

    at ../src/cart.js:2:35
    at Array.map (<anonymous>)
    at t (../src/cart.js:2:16)
    at Object.<anonymous> (../src/main.js:3:13 [totalPrice])

[…] の中は、mapの names に記録されていたシンボル名です。フレームによって、関数名ではなく呼び出し先の名前が入ることがあります。上の [totalPrice] は、main.js の3行目で呼んでいる関数の名前です。原因の特定には位置を使い、名前は補助として読みます。

claude -p に渡す

戻したスタックは、claude -p の標準入力にパイプで渡せます。標準入力で受け取れるのは10MBまでで、超えると非ゼロの終了コードで止まります。ソースマップ本体は大きくなりがちなので、mapをパイプせず、戻した結果のスタックだけを渡します。

node resolve-stack.mjs dist < stack.txt \
  | claude --bare -p "このスタックの最上位フレームが指す行を読み、\
nullが入りうる経路をsrc配下から特定して、原因と修正案を示して。\
ファイルは変更しない" \
  --allowedTools "Read,Grep,Glob"

--bare を付けると、フック・MCPサーバー・自動メモリー・CLAUDE.mdの自動読み込みが無効になります。CIや他人のマシンでも同じ条件で動かしたいときに向きます。bareモードはOAuthの資格情報を読まないので、ANTHROPIC_API_KEY の設定が必要です。--allowedTools は読み取り系の3つだけにしています。このコマンドは調査用なので、Edit や Bash は許可しません。

手順

本番エラーから原因箇所までの流れ

  1. 1

    スタックを集める

    監視ツールやログから、minifyされたスタックを stack.txt に保存します。

  2. 2

    ビルドの map を取り出す

    そのエラーが出たデプロイと同じビルドの .map を、CIの成果物から取得します。

  3. 3

    位置を元のコードに戻す

    resolve-stack.mjs で、ファイル・行・列を元の値に置き換えます。

  4. 4

    Claude Code に原因を探させる

    戻したスタックを claude -p に渡し、読み取り専用で調べさせます。

フレームワーク内部のフレームを読ませない

mapには ignoreList という任意のフィールドがあり、フレームワークやバンドラーが生成したコードなど、第三者のコードにあたる sources の番号を列挙できます。仕様では、開発ツールが開発者の見たくないコードを避けるための印とされています。スクリプトで戻すときも、この番号に当たるフレームを出力から外せば、Claude Codeに渡すスタックはアプリ自身のコードだけに絞れます。

ソースマップを本番に公開しない出し方

ソースマップを公開サーバーに置くと、sourcesContent を含む場合は元のソースコードがそのまま配布されます。仕様でも、sourcesContent は元ソースを別に置けない場合のために、ソース本文を埋め込む任意のフィールドとされています。エラー調査にだけ使いたい場合は、次の2点を分けて設定します。

  • JSにmapの場所を書かない: esbuildの --sourcemap=external は、.map だけを出力して sourceMappingURL コメントを付けません。webpackなら hidden-source-map が同じ働きで、ドキュメントには「エラーレポートのスタックを戻す用途に向く」と書かれています
  • mapに元コードを埋め込まない: esbuildの --sources-content=false、webpackの nosources-source-map で sourcesContent を省けます。省くと、位置は戻せても元コードの本文はmapに入りません

nosources-source-map は、ソース全体を露出させずにクライアントのスタックを戻す用途として説明されています。ただしこの記事の目的では、mapをサーバーに置く必要自体がありません。dist と .map を同じコミットのビルド成果物としてCIのアーティファクトに保存しておき、調査時に手元へ取ってくれば足ります。手元には元のリポジトリがあるので、sourcesContent が無くてもClaude Codeは src/cart.js を直接読めます。

Claude Code側の権限とCLAUDE.md

ソースマップを手元に置いても、Claude Codeに読ませたくない場合があります。.map に元コードが埋め込まれていると、sourcesContent が会話に流れ込みます。ファイルツールの読み取りを止めるには、権限ルールの Read denyを使います。

{
  "permissions": {
    "deny": ["Read(**/*.map)"]
  }
}

この書式は、Read(./.env) や Read(./secrets/**) のように、パスのパターンで読み取りを拒否するルールに沿ったものです。mapはスクリプトが読むので、Claude Code自身が開く必要はありません。Claude Codeには戻した後のスタックだけが見えます。

調査の手順を毎回プロンプトに書かないよう、プロジェクトのCLAUDE.mdには次のような断片を置いておけます(文言は一例です)。

## 本番エラーの調査
- スタックトレースの `dist/*.js:行:列` は圧縮後の位置。`resolve-stack.mjs` で戻した値だけを信じる
- 戻した位置の関数を読んだら、呼び出し元を最大2段までたどって原因を絞る
- 修正案を出すときは、再現する最小の入力値も添える

戻した行がずれるとき、戻らないとき

落とし穴

列を0始まりのまま扱って、隣の行を読んでしまう

findEntry の入出力は0始まりですが、スタックに出る行・列は1始まりです。引く前に1を引き、表示する前に1を足します。片側だけ直すと、1行ずれた位置をClaude Codeが読み始めます。

他にも、戻らない原因は決まったパターンに収まります。

症状原因の見当
フレームが元のまま残る原因の見当mapのファイル名がJSと対応していない。basename で引いているので、app.js と app.js.map を同じ階層に置く
全フレームが node:internal原因の見当戻す対象のフレームが無い。アプリのコードを通らず、ランタイム内で起きたエラー
位置は戻るが中身が別物原因の見当別デプロイのmapを使っている。コミットとアーティファクトの対応を確認する
--enable-source-maps で変わらない原因の見当external で出力していて、JS末尾に sourceMappingURL が無い。linked にするか、スクリプトで戻す

Sentryのような監視サービスに送る方法は、Claude CodeにSentry CLIのスキルを入れてissue調査を任せるで扱っています。ここで書いたのは、サービスを介さず手元のmapだけで完結させる経路です。claude -p の権限モードや --bare の詳細は、claude -pオプションの解説にまとめています。

まとめ

minify済みのスタックは、行:列を元のファイル・行・関数名に戻してからClaude Codeへ渡すと、読むべきコードが最初から決まります。戻す処理はNode組み込みの SourceMap.findEntry で書け、0始まりと1始まりの差だけが落とし穴です。mapは公開せず、ビルドと同じコミットのCI成果物として保管し、調査時に手元で引く構成にすれば、元コードを配布せずに済みます。ブラウザのエラーのようにNodeが自動で戻さない場面ほど、この手元での解決が効きます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/headless
  2. code.claude.com/docs/en/permissions
  3. tc39.es/ecma426
  4. nodejs.org/api/module.html
  5. nodejs.org/api/cli.html
  6. esbuild.github.io/api
  7. webpack.js.org/configuration/devtool
この記事を共有:XはてブLinkedIn