TypeScript 語言伺服器對正確的檔案報出 TS5097 — 刪掉 .ts 之後指令碼就再也跑不起來

TypeScript 語言伺服器對正確的 import 報出 TS5097,刪掉 .ts 之後 Node 指令碼就壞了,但 tsc 依然全綠。真正的原因是 tsconfig 的 extends 解析失敗。

藍色卡片上的白色 TypeScript TS 標誌與字樣
本頁目錄

引言

編輯器在一行已經正常運作好幾個月的 import 底下畫上了紅色波浪線。訊息點名的那個編譯器 flag,我相當確定早就是開著的,於是我把它抱怨的 .ts 副檔名刪掉,警告就消失了。警告會消失,是因為我把這個檔案弄壞了:指令碼再也無法啟動,而 repo 裡的每一項檢查依然全部通過。真正的原因是一個無法解析 tsconfig extends 的 TypeScript 語言伺服器,而解析失敗會悄悄丟掉所有繼承而來的編譯器選項,包含這個錯誤訊息所講的那一個。

本文會依序走過這個假錯誤、那個讓情況更糟的「修正」、既有關卡為什麼都沒有察覺,以及最後同時解釋兩個症狀的診斷。文章結尾會介紹現在每次變更後都會執行的小型檢查,不過真正能帶走的是診斷的部分。

與建置結果不一致的警告

出問題的檔案是 scripts/glossary-check.ts,是這個部落格 repo 裡約二十個維護用指令碼的其中一個。VS Code 的 Problems 面板對第 23 行顯示:

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

這就是 TS5097。這個說法是可以查證的,而它是錯的。flag 本來就是開的,透過這個 repo 自己的 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

整個 repo 裡有 16 個檔案是明確帶著 .ts 副檔名 import 的,共 37 個 import 陳述式。如果這個 flag 真的關著,被指出來的應該是全部 16 個,而不是 1 個。編輯器和編譯器讀的是同一個檔案,卻得出不同的結論。 那一刻該做的是追問為什麼,而不是去安撫比較大聲的那一邊。

刪掉 .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 也死了。沒有任何東西提醒這件事。編輯器很安靜,因為在它看來錯誤已經修好了。

編輯器在兩個方向上都判斷錯誤圖中有兩欄。左欄是以 .ts 結尾的原始 import,編輯器回報 TS5097,但指令碼實際上正常執行。右欄是刪掉副檔名後的同一行 import,編輯器不再回報任何問題,但 Node 以 ERR_MODULE_NOT_FOUND 失敗。編輯器在兩欄都判斷錯誤:先是對正確的程式碼發出警告,接著對壞掉的程式碼保持沉默。編輯器的判定,對照 Node 實際的結果原本的 importimport 指定字串"../src/i18n/config.ts"編輯器回報 TS5097這個 flag 一直都是開的Node指令碼跑得起來pnpm check:glossary 通過很吵,但程式碼是對的刪掉副檔名之後import 指定字串"../src/i18n/config"編輯器沒有任何診斷已經沒東西可回報NodeERR_MODULE_NOT_FOUND指令碼無法啟動很安靜,但程式碼已經壞了
訊號是反過來的:程式碼正確的時候很吵,一壞掉反而安靜了。

值得記住的就是這個形狀。 一個往吵鬧方向出錯的診斷只是煩人,往安靜方向出錯的診斷才危險。而這兩件事,是同一個壞掉的語言伺服器先後做出來的。

為什麼型別檢查依然是綠的

這個 repo 的指令碼是以 node scripts/*.ts 直接執行的,跑在 Node 原生的型別剝離上。沒有打包工具,沒有 tsx,也沒有建置步驟。型別剝離做的事就如其名:它抹掉型別註記,其餘原封不動,包含 import 的指定字串。

另一邊,TypeScript 設定的是 moduleResolution: "Bundler",它會像打包工具那樣,愉快地把 "./config" 解析成 config.ts。兩個工具,一個指定字串,答案完全相反。

我在一個暫存目錄裡把三種寫法都試過:

指定字串tsc --noEmitnode file.ts
"./lib"通過ERR_MODULE_NOT_FOUND
"./lib.js"通過ERR_MODULE_NOT_FOUND
"./lib.ts"通過正常執行

.js 這種寫法是從 ts-node 和會產出檔案的設定帶過來的反射動作,在那些情境裡編譯器會寫出 .js 讓 Node 之後找得到。這裡不會產出任何檔案,所以那個檔案並不存在,指定字串指向的是空的。

所以型別檢查是綠的,並不能證明指令碼跑得起來。 這不是任何一邊的 bug。tsc 回答的是關於型別的問題,而一個模組指定字串在另一套完全不同的解析器底下能不能解析,並不是那個問題。但這確實表示:這一類的損壞,對多數 repo 視為權威的那道關卡而言是看不見的。

原因是一個解析失敗的 extends

真正的診斷是在第二個警告出現時才浮現的,這次針對的是 tsconfig.json 本身:

File 'astro/tsconfigs/strict' not found.

這不是第二個 bug,而是第一個的原因。當 extends 解析失敗時,TypeScript 失去的不只是那個讀不到的檔案,而是整個退回自己的預設值,所有原本會繼承的選項也一起消失。allowImportingTsExtensions 預設是關的,所以處在那個狀態的語言伺服器,必然會對正確的檔案報出 TS5097。兩個症狀,一個根源。

檔案本身存在,套件也正確地在 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。它的 store 目錄名稱帶有每次 install 都會變的 peer hash,而現在只剩下一個 astro@* 目錄,所以語言伺服器先前解析並快取起來的那條路徑已經不存在了。repo 裡什麼都沒有改變。 移動的是一個長時間執行的行程腳下的地面,而那個行程仍然充滿自信地繼續作答。

一次 install,隔了四步,變成一個無關 flag 的錯誤圖中是五個步驟的縱向鏈條。pnpm install 重寫 node_modules,使 store 路徑改變,語言伺服器因此抓著一個已經不存在的路徑。接著 tsconfig 的 extends 解析失敗,而 extends 一旦失敗就會丟掉所有繼承來的編譯器選項,allowImportingTsExtensions 回到預設的關閉狀態,於是對正確的程式碼報出 TS5097。原因在第一步,看得見的症狀卻在最後一步。1執行 pnpm install21:47 時 node_modules 被重寫2store 路徑改變帶 peer hash 的目錄名換了,舊的已經不存在3語言伺服器仍抓著舊路徑它在 install 之前就解析並快取了這個路徑4extends 解析失敗File 'astro/tsconfigs/strict' not found5所有繼承來的選項全部失效allowImportingTsExtensions 回到預設的關閉,於是報出 TS5097真正的原因你看到的症狀
一次 install 讓快取起來的路徑失效,而這個失敗在四步之外,才以一個無關的編譯器 flag 錯誤浮上檯面。

我依照代理的建議重啟了 TypeScript 語言伺服器。兩個警告同時消失,這也證實了原因只有一個,而不是兩個湊巧同時發生的問題。

為兩種訊號都涵蓋不到的部分加上關卡

到這裡,兩種訊號都已經被證明在相反的方向上不可信。編輯器可能非常有自信地判斷錯誤,而且它只會回報剛剛編輯過的那個檔案,所以把上游的 import 弄壞根本不會產生任何診斷。tsc 在型別上是正確的,但對於指令碼能不能啟動,它在結構上完全看不到。

我要求一個會自己跑起來的東西。代理提出一個在每一輪結束時觸發的 hook,而我選了它,而不是每次編輯檔案後就跑一次:這次的起因就在一個沒有人碰過的檔案裡,而且逐次編輯的檢查還會回報那些下一次編輯就會修好的中間狀態。

在這個設計值得動手之前,有三件事必須先量過,因為其中任何一件不成立都會讓設計垮掉。代理寫了一個用完即丟的 hook,指向一個記錄檔,然後確認實際上什麼東西會送達:

通道會送到代理嗎
exit 0,純標準輸出不會。 標記從來沒有送達
exit 0,JSON 的 hookSpecificOutput.additionalContext會
exit 2,標準錯誤輸出會,以阻斷式錯誤的形式

一個只是把結果 echo 出來的 hook,會執行、會正常結束,然後什麼都沒告訴任何人。 當一個 hook 明明有觸發卻好像沒有作用時,這是第一個該檢查的地方。這道檢查本身刻意只做建議而不阻斷:在這個事件上以非零狀態結束會讓該輪無法結束,只要有一個修不掉的錯誤就會永遠繞下去。

檢查的後半段在找 Node 會拒絕的指定字串。它不是直接 grep scripts/ 目錄,而是從那裡往外走過 import 圖,這個差別並不只是表面功夫。那些指令碼對 src/ 有 17 條依賴,而 Node 會把它們拉進來的東西一併載入,所以同一條規則也適用於那些檔案。另一方面,src/i18n/article-locale.ts 用沒有副檔名的方式 import "./config",卻完全沒有問題。scripts/ 底下沒有任何東西走得到它,而走得到它的一切都由 Vite 負責解析。

決定判定的是可達性,不是檔案位置左邊是一張卡片,列出由 node 直接執行的指令碼檔案。右邊有兩張原始碼卡片。上面那張可以從指令碼經由十七條依賴走到,因此其中沒有副檔名的 import 會被回報為錯誤。下面那張沒有任何指令碼走得到,因此同一種沒有副檔名的 import 會被正確地忽略,因為只有 Vite 會去解析那個檔案。scripts/ — 由 node 直接執行glossary-check.tsrelated-preview.tsbuild-sw.ts另外 20 個17 條依賴沒有路徑src/ — 指令碼會走到這裡i18n/config.tslib/related.tslib/site.tsNode 也會載入這些檔案src/ — 沒有任何指令碼走得到i18n/article-locale.ts這個檔案只有 Vite 會解析這裡沒有副檔名的 import 會被回報這裡沒有副檔名的 import 會被忽略
src/ 裡兩個沒有副檔名的 import,判定卻完全相反。分開它們的只有可達性,而那正是平坦的 grep 看不到的東西。

用 grep 掃 scripts/ 的做法,只能在兩種結果之間二選一:漏掉 src/ 裡真正的損壞,或是把正確的檔案報成壞的。決定判定的是可達性,而它算起來很便宜。

驗證

這道關卡跑完只要 0.86 秒,相較之下單獨的 tsc --noEmit 是 1.3 秒,astro check 是 11.4 秒,後者因此沒有被放進來。當工作目錄乾淨時,它什麼都不會輸出,安靜的一輪就維持安靜。

把它指向最初那個 bug,它會說出編輯器從來沒說過的話:

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。第二個正是以檔案為單位的編輯器診斷做不到的事。

合併前的程式碼審查找出三個缺陷,其中最嚴重的一個就在失敗路徑上。existsSync 對目錄會回傳 true,因此對目錄呼叫 readFileSync 會丟出 EISDIR。沒有任何東西接住它,於是整道檢查會什麼都不輸出就結束,tsc 連跑都沒跑到。對一道以沉默作為成功訊號的關卡來說,這種失敗方式和成功根本分不出來。 另外兩個是誤判,把註解掉的 import 或換行折起來的 import type 讀成致命問題。

總結

  • extends 解析失敗會丟掉所有繼承而來的編譯器選項。 由此產生的錯誤指向的是 flag,而不是那條解析不到的路徑,所以請先診斷 extends 的失敗,其餘的都當成它的下游。
  • 讓語言伺服器過期的是 pnpm install。 store 目錄名稱帶有每次 install 都會變的 peer hash,快取起來的解析路徑會因此不再存在。install 之後請重啟語言伺服器,而且在動手改程式碼去安撫波浪線之前,先懷疑這件事。
  • 當編輯器和 tsc 不一致時,以 tsc 為準。 用 tsc --showConfig 來定案,它印的是合併後的設定,而不是那個檔案。
  • 型別檢查通過不代表指令碼跑得起來。 在 moduleResolution: "Bundler" 搭配 Node 原生型別剝離的組合下,沒有副檔名的相對指定字串會順利通過型別檢查,然後在啟動時失敗。"./lib" 和 "./lib.js" 都會失敗,只有真正的副檔名可行。
  • 絕對不要隔著管線去確認指令碼。 node script.ts | tail 回報的是 tail 的結束碼。

參考連結

分享這篇文章