TypeScript の言語サーバーが正しいファイルに TS5097 を出した — .ts を消したらスクリプトが壊れた話

TypeScript の言語サーバーが正しい import に TS5097 を報告し、.ts を消したら Node のスクリプトが動かなくなりました。tsc は緑のままです。原因は tsconfig の extends の解決失敗でした。

青いカードに白のTypeScriptのTSロゴとロゴタイプ
目次

はじめに

エディタが、何か月も動いていた import に赤い波線を出しました。メッセージが名指ししていたのは、すでに有効になっているはずのコンパイラフラグです。そこで警告の対象になっていた .ts を消したところ、警告は消えました。消えたのは、私がそのファイルを壊したからです。 スクリプトはもう起動できなくなり、それでもリポジトリのチェックはすべて通り続けました。原因は、tsconfig の extends を解決できなくなっていた言語サーバーでした。解決に失敗すると、継承されるはずのコンパイラオプションが、エラーの対象になっていたものも含めてすべて黙って失われます。

本記事では、この偽のエラー、事態を悪化させた「修正」、既存のどのチェックも気づかなかった理由、そして 2 つの症状を同時に説明した診断までを順に整理します。最後に、変更のたびに走るようになった小さなチェックを紹介しますが、応用が利くのは診断のほうです。

ビルドと食い違った警告

対象は scripts/glossary-check.ts で、このブログのリポジトリにある 20 個ほどのメンテナンス用スクリプトのひとつです。VS Code の Problems パネルには、23 行目に対してこう出ていました。

An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.

これが TS5097 です。主張は検証できるもので、そしてそれは誤りでした。フラグは有効で、このリポジトリの tsconfig.json を経由して astro/tsconfigs/base.json から継承されています。

node_modules/astro/tsconfigs/base.json
// Allow importing TypeScript files using their native extension (.ts(x)).
"allowImportingTsExtensions": true,

コンパイラを直接動かすと、同じ判断になります。

ターミナルウィンドウ
$ pnpm dlx tsc --noEmit -p tsconfig.json | grep -c "TS5097"
0

リポジトリ全体では 16 個のファイルが .ts を明示して import しており、その文は 37 個あります。フラグが本当に無効なら、指摘されるのは 1 個ではなく 16 個すべてのはずです。エディタとコンパイラは同じファイルを読んで、違う結論を出していました。 本来はここで、声の大きいほうを黙らせるのではなく、理由を疑うべきでした。

.ts を消して、スクリプトが動かなくなった

私は代わりに拡張子を消しました。

scripts/glossary-check.ts
- import { LOCALES, LOCALE_LABEL } from "../src/i18n/config.ts";
+ import { LOCALES, LOCALE_LABEL } from "../src/i18n/config";

波線は消えました。スクリプトも動かなくなりました。

Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/…/oharu-tech-blog/src/i18n/config'
imported from /…/oharu-tech-blog/scripts/glossary-check.ts

pnpm check:glossary は死に、それを連鎖して呼ぶ pnpm check も死にました。それを伝えるものは何もありません。エディタは静かでした。エディタから見れば、エラーは修正されたからです。

エディタは両方向に間違えた2 つの列がある。左は .ts で終わる元の import で、エディタは TS5097 を報告するがスクリプトは正常に動く。右は拡張子を削除した同じ import で、エディタは何も報告しないが Node は ERR_MODULE_NOT_FOUND で失敗する。エディタは両方の列で間違っており、最初は正しいコードに警告を出し、次は壊れたコードに沈黙している。エディタの判定と、実際に Node が返した結果元の importimport 指定子"../src/i18n/config.ts"エディタTS5097 を報告フラグは最初から有効Nodeスクリプトは動くpnpm check:glossary は成功騒がしいが、コードは正しい拡張子を削除した後import 指定子"../src/i18n/config"エディタ警告なし報告すべきものがないNodeERR_MODULE_NOT_FOUNDスクリプトが起動しない静かだが、コードは壊れている
信号は反転していました。コードが正しいうちは騒がしく、壊れた瞬間に静かになりました。

覚えておく価値があるのはこの形です。 診断が騒がしい方向に間違っているのは煩わしいだけです。静かな方向に間違っているのは危険です。そして同じ壊れた言語サーバーが、その両方を順番に起こしました。

型チェックが緑のままだった理由

このリポジトリのスクリプトは、Node のネイティブな型ストリッピングの上で node scripts/*.ts として素のまま実行されます。バンドラーも tsx もビルド手順もありません。型ストリッピングは名前のとおりのことをします。型注釈を消し、import の指定子を含めてそれ以外はそのまま残します。

一方 TypeScript には moduleResolution: "Bundler" が設定されています。バンドラーがそうするように、"./config" を config.ts として解決します。ツールは 2 つ、指定子はひとつ、答えは正反対です。

作業用のディレクトリで 3 つの形をすべて試しました。

指定子tsc --noEmitnode file.ts
"./lib"通るERR_MODULE_NOT_FOUND
"./lib.js"通るERR_MODULE_NOT_FOUND
"./lib.ts"通る動く

.js の形は ts-node や emit を伴う構成での癖です。そこではコンパイラが .js を書き出し、Node が後でそれを見つけます。ここでは何も emit しないので、そのファイルは存在せず、指定子は何も指していません。

つまり型チェックが緑であることは、スクリプトが起動できる証拠にはなりません。 これはどちらのツールのバグでもありません。tsc が答えているのは型についての問いであり、まったく別のリゾルバの下でモジュール指定子が解決するかどうかは、その問いではありません。ただしこの種の破損は、多くのリポジトリが権威として扱っているチェックからは見えない、ということでもあります。

原因は extends の解決失敗ひとつ

本当の診断は、2 つ目の警告が出たときに分かりました。今度は tsconfig.json 自体に対してです。

File 'astro/tsconfigs/strict' not found.

これは 2 つ目のバグではありません。1 つ目の原因です。extends の解決に失敗すると、TypeScript は読めなかったファイルだけを失うのではなく、自分の既定値に戻ります。 継承されるはずだったオプションもすべて一緒に失われます。allowImportingTsExtensions は既定で無効なので、その状態の言語サーバーは正しいファイルに対して TS5097 を報告するしかありません。症状は 2 つ、原因はひとつです。

ファイル自体は存在し、パッケージも astro@7.2.9 の exports マップで ./tsconfigs/*.json と ./tsconfigs/* として正しく公開していました。マージ後の設定も、コマンドラインからはきれいに解決します。

ターミナルウィンドウ
$ pnpm exec tsc --showConfig -p tsconfig.json
strict: true | allowImportingTsExtensions: true | moduleResolution: bundler

覚えておく価値があるのはこのコマンドです。マージ済みの設定を表示するので、tsconfig.json を読むだけでは決着しない問いに答えてくれます。ファイルが記録しているのは書かれた内容だけであり、書かれた内容は最初から争点ではありませんでした。

変わっていたのは、その下の層です。

ターミナルウィンドウ
$ ls -ldT node_modules/.pnpm node_modules/astro
Sep 3 21:47:36 2026 node_modules/.pnpm
Sep 3 21:47:36 2026 node_modules/astro -> .pnpm/astro@7.2.9_…_6de70ef1d7752d74750f06aa0e0620e0/…

前の晩に pnpm が node_modules を書き換えていました。ストアのディレクトリ名には install ごとに変わる peer ハッシュが付き、残っている astro@* のディレクトリはひとつだけです。言語サーバーが解決してキャッシュしていたパスは、もう存在しませんでした。リポジトリの中では何も変わっていません。 長く動き続けるプロセスの足元が動き、そのプロセスは自信を持って答え続けていました。

1 回の install から 4 段階先、無関係なフラグのエラーへ5 つの段階が縦に連なる。pnpm install が node_modules を書き換え、ストアのパスが変わるため、言語サーバーは存在しないパスを保持したままになる。その結果 tsconfig の extends の解決に失敗し、extends が失敗すると継承されるコンパイラオプションがすべて失われるため、allowImportingTsExtensions は既定の無効に戻り、正しいコードに対して TS5097 が報告される。原因は最初の段階にあり、目に見える症状は最後の段階に出る。1pnpm install が走る21:47 に node_modules が書き換わる2ストアのパスが変わるpeer ハッシュ付きの名前が変わり、古いディレクトリは消える3言語サーバーは古いパスを持ち続けるinstall の前に解決してキャッシュしていた4extends の解決に失敗するFile 'astro/tsconfigs/strict' not found5継承されるオプションがすべて失われるallowImportingTsExtensions は既定で無効になり TS5097 が出る本当の原因目に見える症状
1 回の install がキャッシュされたパスを無効にし、その失敗は 4 段階先で、無関係なコンパイラフラグのエラーとして表に出ます。

私はエージェントの助言を受けて TypeScript の言語サーバーを再起動しました。2 つの警告は同時に消え、それが原因はひとつだったことの裏付けになりました。偶然重なった別々の問題ではなかったということです。

どちらの信号も拾えないものを見張る仕組み

この時点で、2 つの信号が正反対の方向に信用できないと分かっていました。エディタは自信を持って間違えることがあり、しかも報告するのは直前に編集したファイルだけです。だから import 元を壊しても警告は出ません。tsc は型については正しく、スクリプトが起動できるかどうかについては構造的に何も見ていません。

私は、自動で走るものが欲しいと頼みました。エージェントはターンの終わりに動くフックを提案し、私はファイルを編集するたびに走らせる案よりそちらを選びました。今回の発端になった破損は誰も触っていないファイルにあり、編集ごとのチェックは次の編集で直る途中の状態まで報告してしまうからです。

設計に取りかかる前に、測っておくべき事実が 3 つありました。どれが外れても設計そのものが崩れるからです。エージェントは使い捨てのフックを書き、ログファイルに向けて、実際に何が届くのかを確かめました。

経路エージェントに届くか
exit 0、素の標準出力届かない。 目印は一度も届きませんでした
exit 0、JSON の hookSpecificOutput.additionalContext届く
exit 2、標準エラー出力ブロッキングエラーとして届く

結果を echo するだけのフックは、動いて、正常終了して、誰にも伝えません。 フックが動いているのに効いていないように見えるとき、最初に確かめるべきはこれです。このチェック自体は、ブロックするのではなく助言に留めてあります。このイベントで異常終了するとターンが終われなくなり、直せないエラーがあると永久に回り続けるからです。

チェックの後半は、Node が拒否する指定子を探します。scripts/ をそのまま grep するのではなく、そこから import グラフをたどります。この違いは見た目だけのものではありません。これらのスクリプトは src/ に対して 17 本の依存を持ち、Node はそこから読み込むものもすべて読み込むので、同じ規則がそれらのファイルにも及びます。一方 src/i18n/article-locale.ts は "./config" を拡張子なしで import していますが、まったく問題ありません。scripts/ の下からそこへ到達するものがなく、到達するすべてのファイルは Vite が解決するからです。

判定を分けるのは場所ではなく到達可能性左には node が直接実行するスクリプトのファイルを並べたカードがある。右にはソースファイルのカードが 2 つある。上のカードはスクリプトから 17 本の依存で到達するため、その中の拡張子なしの import は失敗として報告される。下のカードにはどのスクリプトからも経路がないため、同じ種類の拡張子なしの import は正しく無視される。そのファイルを解決するのは Vite だけだからである。scripts/ — node が直接実行するglossary-check.tsrelated-preview.tsbuild-sw.tsほか 20 個17 本の依存経路なしsrc/ — スクリプトから到達するi18n/config.tslib/related.tslib/site.tsこれらも Node が読み込むsrc/ — スクリプトからは到達しないi18n/article-locale.tsこちらは Vite しか解決しないここでの拡張子なしの import は報告されるここでの拡張子なしの import は無視される
src/ にある 2 つの拡張子なしの import は、判定が正反対になります。分けているのは到達可能性だけで、それは平坦な grep には見えません。

scripts/ を grep するやり方では、src/ の中の本物の破損を見逃すか、正しいファイルを壊れていると報告するかのどちらかになります。判定を分けているのは到達可能性であり、その計算は安上がりです。

検証

このチェックは 0.86 秒で終わります。tsc --noEmit 単体が 1.3 秒、astro check が 11.4 秒なので、後者は入れませんでした。ツリーがきれいなときは何も出力しません。 静かなターンは静かなままです。

発端になったバグに向けると、エディタが一度も言わなかったことを言います。

1 unresolvable import(s) reachable from scripts/ — invisible to tsc, fatal at runtime:
scripts/glossary-check.ts(23): import "../src/i18n/config" has no extension —
Node cannot resolve it (tsc accepts it; the script will not run).

本物の型エラーを与えた場合は、編集した行のエラーに加えて、編集がまったく触れていない 24 行離れた箇所の TS2367 も報告しました。この 2 つ目こそ、ファイル単位のエディタ診断には出せないものです。

マージ前のコードレビューは 3 件の欠陥を見つけ、そのうち最悪のものは失敗時の経路にありました。existsSync はディレクトリに対して true を返すため、そこに readFileSync を掛けると EISDIR を投げます。それを受け止めるものがありませんでした。チェック全体が何も出力せずに終了し、tsc は一度も走りません。成功の合図が沈黙であるチェックにとって、この失敗の仕方は成功と区別がつきません。 残る 2 件は誤検出で、コメントアウトされた import や折り返された import type を致命的なものとして読んでいました。

まとめ

  • extends の解決に失敗すると、継承されるオプションがすべて失われます。 そのとき出るエラーが指すのはフラグであって、解決できなかったパスではありません。まず extends の失敗を診断し、残りはその下流として扱ってください。
  • 言語サーバーを陳腐化させるのは pnpm install です。 ストアのディレクトリ名には install ごとに変わる peer ハッシュが付くため、キャッシュされた解決済みのパスが存在しなくなります。install の後は言語サーバーを再起動し、波線を黙らせるためにコードを編集する前に、まずこれを疑ってください。
  • エディタと tsc が食い違ったら、tsc が勝ちます。 決着は tsc --showConfig でつけます。ファイルではなくマージ後の設定が表示されます。
  • 型チェックが通ることは、スクリプトが動くことを意味しません。 moduleResolution: "Bundler" と Node のネイティブな型ストリッピングの組み合わせでは、拡張子なしの相対指定子は型チェックを通り、起動時に失敗します。"./lib" も "./lib.js" も失敗し、通るのは本物の拡張子だけです。
  • スクリプトの確認をパイプ越しにしてはいけません。 node script.ts | tail が返すのは tail の終了コードです。

参考リンク

この記事をシェア