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.

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:
// Allow importing TypeScript files using their native extension (.ts(x))."allowImportingTsExtensions": true,Running the compiler directly agreed:
$ pnpm dlx tsc --noEmit -p tsconfig.json | grep -c "TS5097"0Sixteen 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:
- 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.tspnpm 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.
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:
| 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:
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:
$ pnpm exec tsc --showConfig -p tsconfig.jsonstrict: true | allowImportingTsExtensions: true | moduleResolution: bundlerThat 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:
$ 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 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.
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 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.
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
extendsdiscards every inherited compiler option. The resulting errors point at flags, not at the unresolved path, so diagnose theextendsfailure first and treat the rest as downstream. - A
pnpm installis 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
tscdisagree,tscwins. Settle it withtsc --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 | tailreports the exit code oftail.



