A stale TypeScript server reported TS5097 on a correct file — deleting the .ts then broke the script

A stale TypeScript server reported TS5097 on a correct import, and deleting the .ts broke a Node script while tsc stayed green. The cause was a failed extends.

The TypeScript TS square logo and wordmark in white on a blue card
On this page

Introduction

My editor put a red squiggle under an import that had been working for months. The message named a compiler flag I was fairly sure was already set, so I deleted the .ts extension it was complaining about, and the warning went away. It went away because I had broken the file: the script could no longer start, and every check in the repo still passed. The cause turned out to be a stale TypeScript language server that could not resolve the tsconfig’s extends, which silently discarded every inherited compiler option including the one the error was about.

This article walks through the false error, the fix that made things worse, the reason no existing gate noticed, and the diagnosis that explained both symptoms at once. It ends with the small check that now runs after every change, though the diagnosis is the part that transfers.

The warning that disagreed with the build

The file is scripts/glossary-check.ts, one of about twenty maintenance scripts in this blog’s repo. VS Code’s Problems panel had this against line 23:

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

That is TS5097. The claim is checkable, and it was false. The flag is set, inherited from astro/tsconfigs/base.json through this repo’s own tsconfig.json:

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

Running the compiler directly agreed:

Terminal window
$ pnpm dlx tsc --noEmit -p tsconfig.json | grep -c "TS5097"
0

Sixteen files across the repo import with an explicit .ts extension, across 37 statements. If the flag were genuinely off, all sixteen would have been flagged, not one. The editor and the compiler were reading the same file and reaching different conclusions, which should have been the moment to ask why rather than to satisfy the louder of the two.

Deleting the .ts extension, and the script that died

I removed the extension instead:

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

The squiggle disappeared. So did the script:

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 was dead, and with it pnpm check, which chains it. Nothing said so. The editor was quiet, because from its point of view the error had been fixed.

The editor was wrong in both directionsTwo columns. On the left, the original import ending in .ts: the editor reports TS5097 while the script runs correctly. On the right, the same import with the extension deleted: the editor reports nothing while Node fails with ERR_MODULE_NOT_FOUND. The editor is wrong in both columns, first by complaining about correct code and then by staying silent about broken code.The editor's verdict, against what Node actually didThe original importImport specifier"../src/i18n/config.ts"EditorTS5097 reportedThe flag was set the whole timeNodeThe script runspnpm check:glossary passesLoud, and the code was correctAfter deleting the extensionImport specifier"../src/i18n/config"EditorNo diagnosticNothing left to reportNodeERR_MODULE_NOT_FOUNDThe script cannot startSilent, and the code was broken
The signal was inverted: loud while the code was correct, silent once it was broken.

This is the shape worth remembering. A diagnostic that is wrong in the noisy direction is annoying. One that is wrong in the quiet direction is dangerous, and the same broken language server produced both in sequence.

Why the typecheck stayed green

The scripts in this repo run as bare node scripts/*.ts on Node’s native type stripping. No bundler, no tsx, no build step. Type stripping does exactly what its name says: it erases type annotations and leaves everything else, including the import specifier, untouched.

TypeScript, meanwhile, is configured with moduleResolution: "Bundler", which happily resolves "./config" to config.ts because a bundler would. Two tools, one specifier, opposite answers.

I tested all three forms in a scratch directory:

Specifiertsc --noEmitnode file.ts
"./lib"passesERR_MODULE_NOT_FOUND
"./lib.js"passesERR_MODULE_NOT_FOUND
"./lib.ts"passesruns

The .js form is the reflex from ts-node and emit-based setups, where the compiler writes a .js file for Node to find later. Nothing emits here, so there is no such file and the specifier points at nothing.

So a green typecheck is not evidence that a script can start. That is not a bug in either tool. tsc is answering a question about types, and whether a module specifier resolves under a completely different resolver is not that question. It does mean this class of breakage is invisible to the gate most repos treat as authoritative.

The cause: one failed extends

The real diagnosis arrived when a second diagnostic showed up, this time against tsconfig.json itself:

File 'astro/tsconfigs/strict' not found.

That is not a second bug. It is the first one’s cause. When extends fails to resolve, TypeScript does not lose only the file it could not read — it falls back to its own defaults, and every option that would have been inherited goes with it. allowImportingTsExtensions is off by default, so a language server in that state is required to report TS5097 on a correct file. Both symptoms, one root.

The file was present and the package exported it properly, as ./tsconfigs/*.json and ./tsconfigs/* in astro@7.2.9’s exports map. The merged configuration also resolved cleanly from the command line:

Terminal window
$ pnpm exec tsc --showConfig -p tsconfig.json
strict: true | allowImportingTsExtensions: true | moduleResolution: bundler

That command is the one worth keeping. It prints the fully merged config, so it settles the question that reading tsconfig.json cannot: the file only records what was written, and what was written was never in dispute.

What had changed was underneath:

Terminal window
$ 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 had rewritten node_modules the previous evening. Its store directories carry a peer-hash suffix that changes on install, and only one astro@* directory remained, so the path the language server had resolved and cached no longer existed. Nothing in the repository had changed. The ground moved under a long-lived process, and that process kept answering with confidence.

One install, four steps later, an error about an unrelated flagA vertical chain of five steps. A pnpm install rewrites node_modules, which changes the store path, so the language server is left holding a path that no longer exists. Its tsconfig extends then fails to resolve, and because a failed extends discards every inherited compiler option, allowImportingTsExtensions reverts to its default of off and TS5097 is reported on correct code. The fault is at the first step; the visible symptom is at the last.1pnpm install runsnode_modules is rewritten at 21:472The store path changesThe peer-hash suffix is new; the old directory is gone3The language server keeps the old pathIt resolved and cached it before the install4extends fails to resolveFile 'astro/tsconfigs/strict' not found5Every inherited option is discardedallowImportingTsExtensions defaults off, so TS5097 firesWhere the fault isWhat you see
One install invalidates a cached path, and the failure surfaces four steps away as an error about an unrelated compiler flag.

I restarted the TypeScript server on the agent’s recommendation. Both diagnostics cleared together, which is what confirmed the single cause rather than two coincident ones.

A gate for what neither signal covers

Two signals had now been shown untrustworthy in opposite directions. The editor can be confidently wrong, and it only ever reports on the file just edited, so breaking an importer produces no diagnostic at all. tsc is correct about types and structurally blind to whether a script can start.

I asked for something that runs on its own. The agent proposed a hook that fires at the end of each turn, and I chose that over running it after every file edit: the failure that started this was in a file nobody had touched, and a per-edit check also reports intermediate states that the next edit fixes.

Three facts had to be measured before the design was worth anything, because any of them could have sunk it. The agent wrote a throwaway hook, pointed it at a log file, and checked what actually arrived:

ChannelReaches the agent?
exit 0, plain stdoutNo. The marker never arrived
exit 0, JSON hookSpecificOutput.additionalContextYes
exit 2, stderrYes, as a blocking error

A hook that echos its finding runs, exits clean, and tells nobody. That is the first thing to check when a hook appears not to work despite firing. The check itself is deliberately advisory rather than blocking: on this event a non-zero exit refuses to let the turn end, which loops forever on any error that cannot be fixed.

The second half of the gate looks for specifiers Node will refuse. It walks the import graph outward from scripts/ rather than grepping that directory, and the difference is not cosmetic. Those scripts reach into src/ on 17 edges, and Node loads whatever they pull in, so the same rule applies to those files. Meanwhile src/i18n/article-locale.ts imports "./config" with no extension and is entirely fine, because nothing under scripts/ reaches it and Vite resolves for everything that does.

Reachability, not location, decides the verdictOn the left, a card listing script files run directly by Node. On the right, two cards holding source files. The upper one is reached from scripts along seventeen edges, so an extensionless import inside it is reported as a failure. The lower one has no path from any script, so the same kind of extensionless import inside it is correctly ignored, because only the bundler ever resolves that file.scripts/ — run by node, no bundlerglossary-check.tsrelated-preview.tsbuild-sw.tsand 20 more17 edgesno pathsrc/ — reached by a scripti18n/config.tslib/related.tslib/site.tsNode loads these files toosrc/ — never reached by onei18n/article-locale.tsOnly Vite ever resolves this oneAn extensionless import here would be reportedAn extensionless import here would be ignored
Two extensionless imports in src/, opposite verdicts. Only reachability separates them, which is what a flat grep cannot see.

A grep of scripts/ would have to either miss a genuine break inside src/ or report a correct file as broken. Reachability is the whole distinction, and it is cheap to compute.

Verification

The gate runs in 0.86s, against 1.3s for tsc --noEmit alone and 11.4s for astro check, which is why the latter stayed out of it. It prints nothing at all when the tree is clean, so a quiet turn stays quiet.

Pointed at the original bug, it says what the editor never did:

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).

Given a genuine type error instead, it reported the error on the edited line and a cascading TS2367 twenty-four rows away, in code the edit never touched. That second one is exactly what a file-scoped editor diagnostic cannot produce.

A code review before merge found three defects, and the worst of them was in the failure path. existsSync returns true for a directory, so readFileSync on one throws EISDIR, and nothing caught it: the whole check would exit with no output, tsc never running. For a gate whose success signal is silence, that failure mode is indistinguishable from a pass. The other two were false positives, where a commented-out import or a wrapped import type was read as fatal.

Summary

  • A failed extends discards every inherited compiler option. The resulting errors point at flags, not at the unresolved path, so diagnose the extends failure first and treat the rest as downstream.
  • A pnpm install is what makes a language server stale. Store directories carry a peer-hash suffix that changes on install, so a cached resolved path stops existing. Restart the server after installing, and suspect this before editing code to satisfy a squiggle.
  • When the editor and tsc disagree, tsc wins. Settle it with tsc --showConfig, which prints the merged configuration rather than the file.
  • A passing typecheck does not mean a script runs. Under moduleResolution: "Bundler" with Node’s native type stripping, an extensionless relative specifier type-checks cleanly and fails at startup. Both "./lib" and "./lib.js" fail; only the real extension works.
  • Never confirm a script through a pipe. node script.ts | tail reports the exit code of tail.

References

Share this article