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

本頁目錄
引言
編輯器在一行已經正常運作好幾個月的 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 繼承而來:
// 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,然後指令碼再也跑不起來
我選擇把副檔名刪掉:
- 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.tspnpm check:glossary 死了,連帶把它串起來的 pnpm check 也死了。沒有任何東西提醒這件事。編輯器很安靜,因為在它看來錯誤已經修好了。
值得記住的就是這個形狀。 一個往吵鬧方向出錯的診斷只是煩人,往安靜方向出錯的診斷才危險。而這兩件事,是同一個壞掉的語言伺服器先後做出來的。
為什麼型別檢查依然是綠的
這個 repo 的指令碼是以 node scripts/*.ts 直接執行的,跑在 Node 原生的型別剝離上。沒有打包工具,沒有 tsx,也沒有建置步驟。型別剝離做的事就如其名:它抹掉型別註記,其餘原封不動,包含 import 的指定字串。
另一邊,TypeScript 設定的是 moduleResolution: "Bundler",它會像打包工具那樣,愉快地把 "./config" 解析成 config.ts。兩個工具,一個指定字串,答案完全相反。
我在一個暫存目錄裡把三種寫法都試過:
| 指定字串 | tsc --noEmit | node 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.jsonstrict: true | allowImportingTsExtensions: true | moduleResolution: bundler值得記住的是這道命令。它印出的是完整合併後的設定,因此能解決光讀 tsconfig.json 無法解決的問題:檔案只記錄了寫了什麼,而寫了什麼從頭到尾都不是爭議所在。
真正改變的東西在底下:
$ ls -ldT node_modules/.pnpm node_modules/astroSep 3 21:47:36 2026 node_modules/.pnpmSep 3 21:47:36 2026 node_modules/astro -> .pnpm/astro@7.2.9_…_6de70ef1d7752d74750f06aa0e0620e0/…pnpm 在前一天晚上重寫了 node_modules。它的 store 目錄名稱帶有每次 install 都會變的 peer hash,而現在只剩下一個 astro@* 目錄,所以語言伺服器先前解析並快取起來的那條路徑已經不存在了。repo 裡什麼都沒有改變。 移動的是一個長時間執行的行程腳下的地面,而那個行程仍然充滿自信地繼續作答。
我依照代理的建議重啟了 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 負責解析。
用 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的結束碼。



