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

- Source: https://oharu121.com/blog/typescript-ts5097-stale-tsserver-failed-extends-node-type-stripping/
- Published: 2026-09-04T15:32:56+09:00
- Tags: TypeScript, Node.js, Developer Tooling, Claude Code

---
## 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:

```text
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`:

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

Running the compiler directly agreed:

```bash
$ 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:

```ts title="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:

```text
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.

*Figure — InvertedSignal: 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.

> **CAREFUL**
>
> **A pipe will hide this from you.** Checking the script with `node scripts/glossary-check.ts | tail` reported `EXIT=0` while the script was dying, because that exit code belongs to `tail`. Run it unpiped, or read `PIPESTATUS`.

## 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:

| Specifier | `tsc --noEmit` | `node file.ts` |
| --- | --- | --- |
| `"./lib"` | passes | `ERR_MODULE_NOT_FOUND` |
| `"./lib.js"` | passes | `ERR_MODULE_NOT_FOUND` |
| `"./lib.ts"` | passes | runs |

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:

```text
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:

```bash
$ 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:

```bash
$ 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.

*Figure — ExtendsCascade: 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:

| Channel | Reaches the agent? |
| --- | --- |
| exit 0, plain stdout | **No.** The marker never arrived |
| exit 0, JSON `hookSpecificOutput.additionalContext` | Yes |
| exit 2, stderr | Yes, as a blocking error |

**A hook that `echo`s 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.

*Figure — ImportGraphWalk: 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:

```text
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.

> **UH OH**
>
> **The first fix pass was incomplete, and retesting is what caught it.** Blanking comments cleared four of the five false-positive cases; a template literal holding example code still reported as a broken import. Backtick literals are now blanked too, which is safe because a static import specifier is never a template literal.

## 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

- [TypeScript: allowImportingTsExtensions, and the noEmit requirement that comes with it](https://www.typescriptlang.org/tsconfig/#allowImportingTsExtensions)
- [TypeScript: moduleResolution, including how Bundler differs from Node16 on extensions](https://www.typescriptlang.org/tsconfig/#moduleResolution)
- [Node.js: running TypeScript natively, and the rule that type stripping never rewrites specifiers](https://nodejs.org/api/typescript.html)
