Stop .gts counting against TypeScript's non-TS program size limit - #247
Draft
wagenet wants to merge 1 commit into
Draft
Stop .gts counting against TypeScript's non-TS program size limit#247wagenet wants to merge 1 commit into
wagenet wants to merge 1 commit into
Conversation
TypeScript's ProjectService sums the size of every program root it does not recognise as TypeScript and, past maxProgramSizeForNonTsFiles (20MB), calls disableLanguageService on the project. A disabled project reports only the files the client currently has open as program roots. parserOptions.projectService opens one file at a time, so against a demoted project every linted file forces a fresh ts.Program. That is several times slower, and it also changes results: ambient declarations the linted file does not import (declare global, module augmentation, standalone .d.ts) are no longer in the program, so type-aware rules see `any` where they would otherwise see a real type. .gts is TypeScript with a template in it and has no business being weighed against a JavaScript budget. There is no way to say so through extraFileExtensions — an extension registered as ScriptKind.TS is dropped from the supported set outright — so report .gts as weightless through the patched ts.sys instead. .gjs is JavaScript and keeps its weight. That patch only lands when this parser replaces ts.sys before typescript-eslint builds its ProjectService, and an app can be over the limit on .js + .gjs alone, so also surface the demotion: hook the public Project#disableLanguageService and warn once, naming the project and disableSizeLimit. Installed at import because the project service is built on the first type-aware parse in the process, which may be a plain .ts file this parser never sees. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 15, 2026
Contributor
🏎️ Benchmark ComparisonParse
Full mitata output
Full mitata output |
NullVoxPopuli
marked this pull request as draft
August 19, 2026 19:29
Member
|
i put this in draft for now -- lemme know when ready for review |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
TypeScript's
ProjectServiceadds up the size of every program root it doesn't recognise as TypeScript. PastmaxProgramSizeForNonTsFiles— 20MB, not configurable — it callsdisableLanguageServiceon the project. A disabled project reports only the files the client currently has open as program roots:parserOptions.projectServiceopens one file at a time. So against a demoted project, every file ESLint lints is new to the program and forces a freshts.Program..gtsis TypeScript with a template in it, and it counts toward that budget. A large enough TypeScript-flavoured Ember app trips a limit meant for apps full of bundled JavaScript, and nothing says so.Classic
parserOptions.projectis unaffected — typescript-estree builds its own program and never consults the heuristic. It only bites the mode typescript-eslint now recommends, which is probably why it's gone unnoticed.The results change, not just the timings
This is the part that made me write it up as a bug rather than a perf tweak. A demoted program contains the open file and whatever it imports, and nothing else. Ambient declarations that nothing imports —
declare global, module augmentation, standalone.d.ts— simply aren't there.Same file, same rules, two projects identical except for total
.gtsbytes:AMBIENT_FLAGstringanywindow.ambientRegistryAmbientRegistryanyCannot find name 'AMBIENT_FLAG',Property 'ambientRegistry' does not exist on type 'Window & typeof globalThis'Every
no-unsafe-*rule,no-floating-promises,restrict-template-expressions— anything that branches onany— reports differently once a project crosses the line. Ember apps lean on registry augmentation, so this isn't hypothetical.Numbers, on the CLI
Synthetic fixture, 22MB of
.gts(313 files) over a 200-module import chain, 300 files linted, measuringparseForESLintonly:ts.PrograminstanceslanguageServiceEnabledfalsetrueThe rising per-file cost is the mechanism: each rebuild is over a slightly larger program, so the gap widens with project size. At 12k files it's the difference between one program build and twelve thousand.
In an editor it goes the other way
Same knob, opposite sign, and I'd rather state it here than have someone discover it.
useProgramFromProjectServicecallsservice.openClientFile(path, codeFullText)on every parse — there's no "buffer unchanged" fast path — and then asks for a synchronized program. Passing text bumps theScriptInfoversion, which dirties every project containing the file, so the cost of a lint scales with the size of the program that then has to resync.On the CLI the text comes from disk and matches what's already there, nothing dirties, and a complete program is the cheap option — that's the table above. In an editor the ESLint extension lints the in-memory buffer, so the text always differs and every lint dirties. A complete program becomes the expensive option, and a demoted one is cheap precisely because it only holds the files you have open.
Measured on the real app from the section below: ~19k files, ESLint's Node API in one long-lived process, 30 edits across two files, median per edit. That app is
.gjs-heavy enough to stay demoted under this PR, so the complete-program arm is reached withdisableSizeLimit— it stands in for what this change produces on an app whose bytes are mostly.gts.That's with the script-kind fix from #248 in. Before it, a complete program cost 3,626 ms per edit and 9,592MB, because the virtual
.mts/.mjstwins this parser fabricates lost their script kind on every sync and got re-parsed — 7,795 files per lint. Most of the gap was ours, but not all of it.Memory is the sharper end. The VS Code ESLint server runs on Node's default ~4.3GB heap unless someone sets
eslint.execArgvorNODE_OPTIONS, and 5,658MB doesn't fit: it crashes rather than slows. ClassicparserOptions.projecthas the same problem for the same reason (5,616MB measured), so it isn't specific toprojectService— any config where the editor holds a complete program needs an explicit heap bump.So for an app whose non-TS bytes are mostly
.gts, this PR flips it from demoted to complete and it inherits both effects. I still think that's the right default: a demoted program silently reports different results, and phantom errors that appear and disappear depending on what you last opened are worse than a heap flag you set once. But it's a trade, and the numbers belong in the PR rather than in a bug report later.Caveats on those numbers: n=1 per arm, ESLint's Node API rather than the VS Code extension, edits appended at end of file. The benchmark suite in this repo reads files from disk, so it only ever measures the CLI path — any editor benchmark that reads from disk shows the opposite result and is wrong.
What this changes
1.
.gtsno longer counts toward the non-TS budget. The honest fix is to tell TypeScript that.gtsis TypeScript, but there's currently no way to say that — see below. So the patchedts.sysreports.gtsas zero bytes and the project gets measured on the JavaScript it actually contains..gjsdeliberately keeps its weight. It's JavaScript with a template; charging it to a JavaScript size budget is correct, not a bug.2. A one-time warning when a project does get demoted, naming the project, the file that tipped it over, and
disableSizeLimit. This hooks the publicProject#disableLanguageService, calls straight through, and changes nothing.What this does not fix
An app can be over the limit on
.js+.gjsalone, and excluding.gtswon't save it. Real numbers from the app where this turned up:That app stays demoted, which is why change 2 matters as much as change 1: it turns an invisible 4x into a line of output that names the fix.
"disableSizeLimit": truein the tsconfig resolves it completely — on the app above it took rebuilds from 358-per-400-files to 1, with slightly lower peak memory in the batch run.Read that alongside the editor section:
disableSizeLimitis unambiguously right for CI and it is the same change that pushes an editor onto a complete program. On the app above that means budgeting a heap flag for the ESLint server too. Which is also why I'd stop short of suggesting the Ember app blueprint shipdisableSizeLimit: trueby default — the CLI case for it is strong, the editor case isn't, and that discussion belongs wherever the blueprint lives rather than here.Known limitation
Change 1 only lands if this parser replaces
ts.sysbefore typescript-eslint builds itsProjectService. That service snapshots{...ts.sys}once, on the first type-aware parse in the process — and if ESLint reaches a plain.tsfile first, that parse happens before this parser is ever invoked, and the patch misses. I measured both orderings:.gts.tsChange 2 is installed at import for exactly this reason, so the warning is reliable even when the workaround isn't. Same caveat already applies to the existing
readFile/fileExists/readDirectorypatches — I've left that alone here rather than widen this PR.Upstream
The real fix belongs in TypeScript, and
extraFileExtensionslooks like the place to declare that.gtsis TypeScript. It isn't, yet —getSupportedExtensionsonly accepts extra extensions registered asDeferredor JS-like, and silently drops anything registered asScriptKind.TS:.gtsregistered asDeferred(what typescript-eslint does today)TS.gtsdropped from the project entirelyTSTwo small hunks make row 3 work: let
getSupportedExtensionsaccept TS-like extra extensions, and have the size check skip them. I've tested that patch against a local TypeScript build; issues filed:The typescript-eslint side is where the warning really belongs, too. TypeScript already emits a public
projectLanguageServiceStateevent carrying the project andlastFileExceededProgramSize, and typescript-eslint already builds aneventHandler— it just wires it to a debug namespace that's off by default. Surfacing it there needs no new API from anyone, and would make the hook in this PR redundant.Testing
tests/size-limit.test.js— unit cover for the patchedgetFileSizeand the warning hooktests/size-limit-project.test.js— end-to-end: builds two 21MB projects in tmp, one carrying its bytes in.gtsand one in.gjs, and asserts the first keeps a whole program while the second demotes and warns. Runs in ~1.4s, and fails onmain.Feature-detects
projectServiceand skips where it's unavailable. Verified green against@typescript-eslint/parser6/7/8 and TypeScript 5.3 / 5.7 / 5.9, pluspnpm --filter '*' testandtest:check.Needs a label for release-plan —
bugseems right.